Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions includes/heartbeat.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@
exit;
}

/**
* Builds the state stored on a user's entry in a post room.
*
* Shared so the two writers of that entry cannot drift apart on its shape.
*
* @access private
*
* @param string $screen_id The screen ID.
* @param bool $locked Whether this write carries a post lock refresh.
* @return array The state to store.
*/
function wp_presence_editor_state( $screen_id, $locked ) {
return array(
'action' => 'editing',
'screen' => $screen_id,
'locked' => (bool) $locked,
);
}

/**
* Enqueues heartbeat and the presence ping script on all admin pages.
*/
Expand Down Expand Up @@ -75,11 +94,6 @@ function wp_presence_enqueue_heartbeat_ping() {
'room' => $room,
'client_id' => 'editor-' . $user_id,
);
// The post-lock bridge writes this entry via the wp-refresh-post-lock heartbeat.
$entries[] = array(
'room' => $room,
'client_id' => 'lock-' . $user_id,
);
}
}
}
Expand All @@ -105,13 +119,12 @@ function wp_presence_enqueue_heartbeat_ping() {
if ( $editor_post_id ) {
$editor_room = wp_presence_post_room( $editor_post_id );
if ( $editor_room ) {
// No tick has carried a lock refresh yet. connectNow() on load makes
// that a single request, not a visible state.
wp_set_presence(
$editor_room,
'editor-' . $user_id,
array(
'action' => 'editing',
'screen' => $screen_id,
),
wp_presence_editor_state( $screen_id, false ),
$user_id
);
}
Expand Down Expand Up @@ -235,13 +248,15 @@ function wp_presence_editor_heartbeat_received( $response, $data, $screen_id ) {
return $response;
}

// Read per tick: a tick carrying no refresh means the lock is no longer
// being held open, which is what the separate row's expiry used to say.
$locked = ! empty( $data['wp-refresh-post-lock']['post_id'] )
&& absint( $data['wp-refresh-post-lock']['post_id'] ) === $post_id;

wp_set_presence(
$room,
'editor-' . $user_id,
array(
'action' => 'editing',
'screen' => $screen_id,
),
wp_presence_editor_state( $screen_id, $locked ),
$user_id
);

Expand Down
32 changes: 20 additions & 12 deletions includes/post-lock-bridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
/**
* Post-lock bridge: writes presence entries alongside post lock heartbeats.
*
* This bridge is transitional. It creates presence entries alongside the
* existing _edit_lock postmeta so both systems coexist. The intent is for
* the block editor (Gutenberg) to consume presence data directly in the
* future — enabling real-time awareness (cursors, selections, who's editing
* which block) rather than the current blunt lock/takeover model.
* This bridge is transitional. It records the lock on the editing user's
* presence entry alongside the existing _edit_lock postmeta so both systems
* coexist. The intent is for the block editor (Gutenberg) to consume presence
* data directly in the future — enabling real-time awareness (cursors,
* selections, who's editing which block) rather than the current blunt
* lock/takeover model.
*
* @package Presence_API
*/
Expand All @@ -18,8 +19,11 @@
/**
* Bridges post-lock heartbeats into presence entries.
*
* Writes a presence entry alongside the existing _edit_lock postmeta
* whenever a post lock is refreshed via Heartbeat.
* Marks the user's entry in the post room as holding the lock whenever a post
* lock is refreshed via Heartbeat, alongside the existing _edit_lock postmeta.
*
* Fallback path, for a client that refreshes the core lock without sending
* presence-editor-ping.
*
* @param array $response The Heartbeat response.
* @param array $data The $_POST data sent.
Expand All @@ -34,6 +38,13 @@ function wp_presence_bridge_post_lock( $response, $data, $screen_id ) {
}

$post_id = absint( $data['wp-refresh-post-lock']['post_id'] );

// The editor handler already wrote this entry from this same payload.
if ( ! empty( $data['presence-editor-ping']['post_id'] )
&& absint( $data['presence-editor-ping']['post_id'] ) === $post_id ) {
return $response;
}

$user_id = get_current_user_id();

if ( ! $user_id || ! current_user_can( 'edit_post', $post_id ) ) {
Expand All @@ -48,11 +59,8 @@ function wp_presence_bridge_post_lock( $response, $data, $screen_id ) {

wp_set_presence(
$room,
'lock-' . $user_id,
array(
'action' => 'editing',
'screen' => $screen_id,
),
'editor-' . $user_id,
wp_presence_editor_state( $screen_id, true ),
$user_id
);

Expand Down
17 changes: 15 additions & 2 deletions includes/widgets/class-wp-presence-widget-active-posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,16 @@ private static function build_active_posts_data() {
);
}

$by_post[ $post_id ]['editors'][] = array(
'user_id' => (int) $entry->user_id,
$editor_id = (int) $entry->user_id;

// A user can hold more than one entry in a room. Rows arrive newest
// first, so the one already seen is the freshest.
if ( isset( $by_post[ $post_id ]['editors'][ $editor_id ] ) ) {
continue;
}

$by_post[ $post_id ]['editors'][ $editor_id ] = array(
'user_id' => $editor_id,
'display_name' => $user->display_name,
'avatar_url' => get_avatar_url( $user->ID, array( 'size' => 24 ) ),
'status' => $status,
Expand All @@ -387,6 +395,11 @@ function ( $a, $b ) {
}
);

// Keyed by user id above; the response is JSON, so hand back a list.
foreach ( $by_post as $index => $post_data ) {
$by_post[ $index ]['editors'] = array_values( $post_data['editors'] );
}

return array_values( $by_post );
}
}
217 changes: 217 additions & 0 deletions tests/test-heartbeat.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,221 @@ public function test_wp_presence_enqueue_heartbeat_ping() {
}
$this->assertTrue( $found_config );
}

/**
* @covers ::wp_presence_editor_heartbeat_received
*/
public function test_editor_heartbeat_writes_presence() {
$post_id = self::factory()->post->create();

wp_set_current_user( self::$editor_id );

$response = wp_presence_editor_heartbeat_received(
array( 'existing' => true ),
array(
'presence-editor-ping' => array(
'post_id' => $post_id,
),
),
'post'
);

$entries = wp_get_presence( wp_presence_post_room( $post_id ) );

$this->assertCount( 1, $entries );
$this->assertSame( 'editor-' . self::$editor_id, $entries[0]->client_id );
$this->assertSame( 'editing', $entries[0]->data['action'] );
$this->assertSame( 'post', $entries[0]->data['screen'] );

$this->assertSame( array( 'existing' => true ), $response );
}

/**
* @covers ::wp_presence_editor_heartbeat_received
*/
public function test_editor_heartbeat_requires_edit_cap() {
$post_id = self::factory()->post->create();

wp_set_current_user( self::$subscriber_id );

wp_presence_editor_heartbeat_received(
array(),
array(
'presence-editor-ping' => array(
'post_id' => $post_id,
),
),
'post'
);

$this->assertCount( 0, wp_get_presence( wp_presence_post_room( $post_id ) ) );
}

/**
* @covers ::wp_presence_editor_heartbeat_received
*/
public function test_editor_heartbeat_marks_locked_when_the_post_lock_refreshes() {
$post_id = self::factory()->post->create();

wp_set_current_user( self::$editor_id );

wp_presence_editor_heartbeat_received(
array(),
array(
'presence-editor-ping' => array(
'post_id' => $post_id,
),
'wp-refresh-post-lock' => array(
'post_id' => $post_id,
),
),
'post'
);

$entries = wp_get_presence( wp_presence_post_room( $post_id ) );

$this->assertCount( 1, $entries );
$this->assertTrue( $entries[0]->data['locked'] );
}

/**
* A tick without a lock refresh is what a stale lock now looks like, so the
* flag has to go back to false rather than linger from the previous write.
*
* @covers ::wp_presence_editor_heartbeat_received
*/
public function test_editor_heartbeat_clears_locked_without_a_post_lock_refresh() {
$post_id = self::factory()->post->create();
$payload = array(
'presence-editor-ping' => array(
'post_id' => $post_id,
),
'wp-refresh-post-lock' => array(
'post_id' => $post_id,
),
);

wp_set_current_user( self::$editor_id );

wp_presence_editor_heartbeat_received( array(), $payload, 'post' );

unset( $payload['wp-refresh-post-lock'] );
wp_presence_editor_heartbeat_received( array(), $payload, 'post' );

$entries = wp_get_presence( wp_presence_post_room( $post_id ) );

$this->assertCount( 1, $entries );
$this->assertFalse( $entries[0]->data['locked'] );
}

/**
* The lock refresh is for a post other than the one being pinged, so it says
* nothing about this room.
*
* @covers ::wp_presence_editor_heartbeat_received
*/
public function test_editor_heartbeat_ignores_a_lock_refresh_for_another_post() {
$post_id = self::factory()->post->create();
$other_id = self::factory()->post->create();

wp_set_current_user( self::$editor_id );

wp_presence_editor_heartbeat_received(
array(),
array(
'presence-editor-ping' => array(
'post_id' => $post_id,
),
'wp-refresh-post-lock' => array(
'post_id' => $other_id,
),
),
'post'
);

$entries = wp_get_presence( wp_presence_post_room( $post_id ) );

$this->assertCount( 1, $entries );
$this->assertFalse( $entries[0]->data['locked'] );
}

/**
* Regression guard for the double entry: one person in one editor is one
* row in the post room, however many heartbeat handlers see the tick.
*
* @covers ::wp_presence_editor_heartbeat_received
* @covers ::wp_presence_bridge_post_lock
*/
public function test_one_editing_user_occupies_one_row() {
$post_id = self::factory()->post->create();
$payload = array(
'presence-editor-ping' => array(
'post_id' => $post_id,
),
'wp-refresh-post-lock' => array(
'post_id' => $post_id,
),
);

wp_set_current_user( self::$editor_id );

wp_presence_editor_heartbeat_received( array(), $payload, 'post' );
wp_presence_bridge_post_lock( array(), $payload, 'post' );

$this->assertCount( 1, wp_get_presence( wp_presence_post_room( $post_id ) ) );
}

/**
* With one entry per editing user there is no second client to clean up on
* pagehide, and registering one would DELETE a row that never existed.
*
* @covers ::wp_presence_enqueue_heartbeat_ping
*/
public function test_pagehide_entries_omit_a_separate_lock_client() {
global $post;

$post_id = self::factory()->post->create();

wp_set_current_user( self::$editor_id );

$post = get_post( $post_id );
set_current_screen( 'post' );

// wp_add_inline_script() appends, so a config printed by an earlier test
// would still be sitting in `extra` and would be the one read back.
wp_deregister_script( 'wp-presence-ping' );

$wp_scripts = wp_scripts();
$wp_scripts->queue = array();
$wp_scripts->done = array();

wp_presence_enqueue_heartbeat_ping();

$config = $this->get_ping_config();
$client_ids = wp_list_pluck( $config['entries'], 'client_id' );

$this->assertContains( 'editor-' . self::$editor_id, $client_ids );
$this->assertNotContains( 'lock-' . self::$editor_id, $client_ids );
}

/**
* Decodes the wpPresenceConfig object handed to presence-ping.js.
*
* @return array The decoded config.
*/
private function get_ping_config() {
$extra = wp_scripts()->registered['wp-presence-ping']->extra;

foreach ( $extra['before'] as $script ) {
if ( ! $script || false === strpos( $script, 'window.wpPresenceConfig =' ) ) {
continue;
}

$json = trim( substr( $script, strpos( $script, '=' ) + 1 ) );

return json_decode( rtrim( $json, ';' ), true );
}

$this->fail( 'The presence ping config was not printed.' );
}
}
Loading
Loading