diff --git a/includes/heartbeat.php b/includes/heartbeat.php index db6c81b..6b84dbd 100644 --- a/includes/heartbeat.php +++ b/includes/heartbeat.php @@ -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. */ @@ -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, - ); } } } @@ -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 ); } @@ -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 ); diff --git a/includes/post-lock-bridge.php b/includes/post-lock-bridge.php index ff3906d..068f96d 100644 --- a/includes/post-lock-bridge.php +++ b/includes/post-lock-bridge.php @@ -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 */ @@ -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. @@ -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 ) ) { @@ -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 ); diff --git a/includes/widgets/class-wp-presence-widget-active-posts.php b/includes/widgets/class-wp-presence-widget-active-posts.php index fe909d4..9ac0ca8 100644 --- a/includes/widgets/class-wp-presence-widget-active-posts.php +++ b/includes/widgets/class-wp-presence-widget-active-posts.php @@ -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, @@ -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 ); } } diff --git a/tests/test-heartbeat.php b/tests/test-heartbeat.php index f693372..ce4ff95 100644 --- a/tests/test-heartbeat.php +++ b/tests/test-heartbeat.php @@ -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.' ); + } } diff --git a/tests/test-post-lock-bridge.php b/tests/test-post-lock-bridge.php index 803af78..3e18839 100644 --- a/tests/test-post-lock-bridge.php +++ b/tests/test-post-lock-bridge.php @@ -60,6 +60,66 @@ public function test_post_lock_bridge_creates_presence() { $entries = wp_get_presence( $room ); $this->assertCount( 1, $entries ); - $this->assertSame( 'lock-' . self::$editor_id, $entries[0]->client_id ); + $this->assertSame( 'editor-' . self::$editor_id, $entries[0]->client_id ); + $this->assertTrue( $entries[0]->data['locked'] ); + } + + /** + * The editor handler writes the same entry from the same payload, so the + * bridge writing again would only cost a second query for the same row. + * + * @covers ::wp_presence_bridge_post_lock + */ + public function test_post_lock_bridge_defers_to_the_editor_ping() { + $post_id = self::factory()->post->create(); + + wp_set_current_user( self::$editor_id ); + + wp_presence_bridge_post_lock( + array(), + array( + 'wp-refresh-post-lock' => array( + 'post_id' => $post_id, + ), + 'presence-editor-ping' => array( + 'post_id' => $post_id, + ), + ), + 'post' + ); + + $this->assertCount( + 0, + wp_get_presence( wp_presence_post_room( $post_id ) ), + 'The bridge should stand down when the editor ping already covers this post.' + ); + } + + /** + * @covers ::wp_presence_bridge_post_lock + */ + public function test_post_lock_bridge_writes_when_the_editor_ping_is_for_another_post() { + $locked_id = self::factory()->post->create(); + $other_id = self::factory()->post->create(); + + wp_set_current_user( self::$editor_id ); + + wp_presence_bridge_post_lock( + array(), + array( + 'wp-refresh-post-lock' => array( + 'post_id' => $locked_id, + ), + 'presence-editor-ping' => array( + 'post_id' => $other_id, + ), + ), + 'post' + ); + + $entries = wp_get_presence( wp_presence_post_room( $locked_id ) ); + + $this->assertCount( 1, $entries ); + $this->assertSame( 'editor-' . self::$editor_id, $entries[0]->client_id ); } } diff --git a/tests/widgets/test-widget-active-posts.php b/tests/widgets/test-widget-active-posts.php index 0fa6a80..0133d27 100644 --- a/tests/widgets/test-widget-active-posts.php +++ b/tests/widgets/test-widget-active-posts.php @@ -236,4 +236,83 @@ public function test_heartbeat_filters_per_post_rather_than_all_or_nothing() { $this->assertCount( 1, $response['presence-active-posts'] ); $this->assertSame( $draft_id, $response['presence-active-posts'][0]['post_id'] ); } + + /** + * Nothing guarantees one row per user in a room, so the widget counts + * people rather than rows. + * + * @covers WP_Presence_Widget_Active_Posts::heartbeat_received + */ + public function test_a_user_holding_two_entries_is_counted_once() { + wp_set_current_user( self::$editor_id ); + + $room = wp_presence_post_room( self::$post_id ); + wp_set_presence( $room, 'editor-' . self::$editor_id, array(), self::$editor_id ); + wp_set_presence( $room, 'other-' . self::$editor_id, array(), self::$editor_id ); + + $response = WP_Presence_Widget_Active_Posts::heartbeat_received( + array(), + array( 'presence-active-posts-ping' => true ), + 'dashboard' + ); + + $this->assertCount( 1, $response['presence-active-posts'][0]['editors'] ); + $this->assertSame( self::$editor_id, $response['presence-active-posts'][0]['editors'][0]['user_id'] ); + } + + /** + * @covers WP_Presence_Widget_Active_Posts::heartbeat_received + */ + public function test_a_users_freshest_entry_decides_their_status() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = wp_presence_post_room( self::$post_id ); + + wp_set_presence( $room, 'stale-' . self::$editor_id, array(), self::$editor_id ); + $wpdb->update( + $wpdb->presence, + array( 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 45 ) ), + array( 'client_id' => 'stale-' . self::$editor_id ), + array( '%s' ), + array( '%s' ) + ); + + wp_set_presence( $room, 'editor-' . self::$editor_id, array(), self::$editor_id ); + + $response = WP_Presence_Widget_Active_Posts::heartbeat_received( + array(), + array( 'presence-active-posts-ping' => true ), + 'dashboard' + ); + + $editors = $response['presence-active-posts'][0]['editors']; + + $this->assertCount( 1, $editors ); + $this->assertSame( 'active', $editors[0]['status'] ); + } + + /** + * The response is JSON, so the editors list has to stay a list. + * + * @covers WP_Presence_Widget_Active_Posts::heartbeat_received + */ + public function test_editors_encode_as_a_json_array() { + wp_set_current_user( self::$editor_id ); + + $room = wp_presence_post_room( self::$post_id ); + wp_set_presence( $room, 'editor-' . self::$editor_id, array(), self::$editor_id ); + wp_set_presence( $room, 'other-' . self::$editor_id, array(), self::$editor_id ); + + $response = WP_Presence_Widget_Active_Posts::heartbeat_received( + array(), + array( 'presence-active-posts-ping' => true ), + 'dashboard' + ); + + $encoded = wp_json_encode( $response['presence-active-posts'][0]['editors'] ); + + $this->assertStringStartsWith( '[', $encoded ); + } }