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
6 changes: 3 additions & 3 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ JavaScript has no `@since` line to sit under, so `@private` on its own is enough

```js
/**
* Builds an avatar stack.
* Creates a tab coordinator shared by the classic scripts and src/.
*
* @private
*/
window.wpPresenceBuildAvatarStack = function ( users, max ) {};
window.wpPresenceCreateTabCoordinator = function ( key, options ) {};
```

</details>
Expand All @@ -167,7 +167,7 @@ Linking late still counts. Release props are assembled when the release goes out

## Releases

Releases are automated by [release-please](https://github.com/googleapis/release-please). Use [Conventional Commits](https://www.conventionalcommits.org/) in the pull request title — release-please reads it to decide the next version and to generate the changelog:
Releases are automated by [release-please](https://github.com/googleapis/release-please). Use [Conventional Commits](https://www.conventionalcommits.org/) in the pull request title, which release-please reads to decide the next version and to generate the changelog:

- `feat: ...` → minor bump
- `fix: ...` → patch bump
Expand Down
20 changes: 17 additions & 3 deletions plugins/presence-api/assets/js/presence-ping.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@
? window.location.search
: '';

// Fired synchronously, ahead of Heartbeat's first tick, so a listener can
// tell "presence-api isn't here" apart from "here, no tick yet."
if ( editorRoom ) {
/**
* Fires synchronously, before Heartbeat's first tick, so a listener can tell "presence-api isn't here" from "here, no tick yet".
*
* @since 0.6.0
*
* @param {string} room The editor's room.
*/
wp.hooks.doAction( 'presence-api.watchingRoom', editorRoom );
}

Expand Down Expand Up @@ -240,7 +245,8 @@
// window, app switched away) so the existing entries expire via
// the default TTL. One early-return suppresses both presence-ping
// and presence-editor-ping, since the consolidated handler emits
// both.
// both. Core's lock refresh goes too, or core would keep the hidden
// tab's lock alive and the post-lock bridge its editor row.
if ( document.visibilityState === 'hidden' ) {
delete data[ 'wp-refresh-post-lock' ];
return;
Expand Down Expand Up @@ -375,6 +381,14 @@
}

hasCollaborators = nowHasCollaborators;
/**
* Fires `presence-api.collaborationStarted` when a second person joins the editor's room, and `presence-api.collaborationEnded` when one is left.
*
* @since 0.6.0
*
* @param {string} room The editor's room.
* @param {number} count How many editors are in the room, you included.
*/
wp.hooks.doAction(
nowHasCollaborators
? 'presence-api.collaborationStarted'
Expand Down
4 changes: 2 additions & 2 deletions plugins/presence-api/assets/js/stale-screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
return;
}

// Tabs on the same screen send an identical ping — key by screenKey.
// Tabs on the same screen send an identical ping, so key by screenKey.
const pingContextKey = 'wp-presence-screen-ping:' + screenKey;

const tabCoordinator = window.wpPresenceCreateTabCoordinator(
Expand Down Expand Up @@ -132,7 +132,7 @@
avatar.src = info.actor_avatar_url;
avatar.width = 24;
avatar.height = 24;
// Decorative — the actor name is already in the adjacent text.
// Decorative, since the actor name is already in the adjacent text.
avatar.alt = '';
avatar.className = 'wp-presence-stale-avatar';
p.appendChild( avatar );
Expand Down
4 changes: 4 additions & 0 deletions plugins/presence-api/assets/js/tab-coordinator.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
'use strict';

/**
* Creates a coordinator for one key, shared by the classic scripts and src/.
*
* @private
*
* @param {string} key Unique lock/channel name.
* @param {Object} [options]
* @param {string[]} [options.relayedKeys] heartbeat-tick response keys to relay from the leader to followers.
Expand Down
5 changes: 2 additions & 3 deletions plugins/presence-api/includes/avatar-stack.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ function wp_presence_enqueue_avatar_stack_style() {
/**
* Renders a small avatar stack for a list of users.
*
* Shared across every surface that shows an overlapping avatar stack (the
* dashboard widget's overflow indicator, the network Sites list column, the
* network dashboard widget) so they all render the stack identically.
* Shared by the network Sites list column and the network dashboard widget,
* so both render the stack identically.
*
* assets/css/avatar-stack.css sizes the avatars; the attributes below only
* reserve the space until it loads.
Expand Down
5 changes: 3 additions & 2 deletions plugins/presence-api/includes/heartbeat.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
/**
* 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.
* Shared so the writers of that entry cannot drift apart on its shape.
*
* @access private
*
Expand Down Expand Up @@ -337,12 +337,13 @@ function wp_presence_register_packages_scripts( $scripts ) {
* This is the API's primary write path. It runs regardless of which dashboard
* widgets are registered.
*
* Nonce verification is handled by WordPress in wp_ajax_heartbeat().
*
* @since 0.1.10
*
* @param array $response The Heartbeat response.
* @param array $data The $_POST data sent.
* @param string $screen_id The screen ID.
* Nonce verification is handled by WordPress in wp_ajax_heartbeat().
*
* @return array The Heartbeat response.
*/
Expand Down
4 changes: 2 additions & 2 deletions plugins/presence-api/includes/network-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,8 @@ function wp_presence_network_capability() {
/**
* Returns the distinct user IDs online anywhere on the network.
*
* Reads the snapshot rather than the summary: the Users list column asks only
* whether a given row's user is in this set, so resolving a display name and
* Reads the snapshot rather than the summary: the Online view, its count and
* the admin bar only ask who is in this set, so resolving a display name and
* an avatar URL for everyone online to answer that would be the whole cost of
* the read for none of its output.
*
Expand Down
3 changes: 2 additions & 1 deletion plugins/presence-api/includes/post-lock-bridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@
* Fallback path, for a client that refreshes the core lock without sending
* presence-editor-ping.
*
* Nonce verification is handled by WordPress in wp_ajax_heartbeat().
*
* @since 0.1.1
*
* @param array $response The Heartbeat response.
* @param array $data The $_POST data sent.
* @param string $screen_id The screen ID.
* Nonce verification is handled by WordPress in wp_ajax_heartbeat().
*
* @return array The Heartbeat response.
*/
Expand Down
33 changes: 18 additions & 15 deletions plugins/presence-api/includes/presence.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
* wp_presence_recording_enabled()
* wp_presence_is_available()
* wp_presence_is_agent_user()
* wp_presence_parse_room()
* wp_get_presence_by_room_prefix()
* wp_get_presence_room_versions()
* wp_get_presence_room_next_expiry()
*
* @package Presence_API
*/
Expand Down Expand Up @@ -378,9 +382,9 @@ function wp_presence_with_current_user( $entries ) {
* Whether presence is recorded on this site.
*
* The controller-level switch, checked at the single write path. Nothing new is
* stored while this is false and every surface empties within one
* WP_PRESENCE_DEFAULT_TTL as the rows already there expire, so there is no
* separate teardown to run.
* stored while this is false and every surface empties as the rows already
* there expire, within one TTL for Heartbeat rows and at most
* wp_presence_max_expires_in() for the rest, so there is no teardown to run.
*
* Recording is on by default. Presence is a negative signal, and the post lock
* bridge is where its absence stops two people overwriting each other, so the
Expand Down Expand Up @@ -1654,8 +1658,8 @@ function wp_presence_is_agent_user( $user_id ) {
/**
* Filters whether a user is an AI agent, for labelling its presence rows.
*
* Every column a presence row's `user_id` might have come from — the
* database, JSON, this function's own callers — reads back as a string,
* Every column a presence row's `user_id` might have come from (the
* database, JSON, this function's own callers) reads back as a string,
* so it is cast to an int before it ever reaches a filter, and a filter
* can compare against a plain int without tripping over the difference.
*
Expand Down Expand Up @@ -1739,9 +1743,9 @@ function wp_presence_on_agent_post_saved( $post_id, $post ) {
*
* An agent runs no Heartbeat and joins no `admin/online` room of its own; it
* writes only the post room it is editing (see the "Agents" section of the
* README). Rather than have it write a second room, Who's Online and the
* admin bar both call this instead of `wp_get_presence( wp_presence_admin_room() )`
* directly, so an agent's row is picked up from wherever it already is.
* README). Rather than have it write a second room, the admin bar calls this
* instead of `wp_get_presence( wp_presence_admin_room() )` directly, so an
* agent's row is picked up from wherever it already is.
*
* An agent user with a row of its own in `admin/online` is left as that row
* reads; only agent users absent from it are backfilled from a post room,
Expand Down Expand Up @@ -1796,8 +1800,8 @@ function wp_presence_admin_room_entries( $timeout = null ) {
/*
*
* The following functions are used by the plugin's widgets, CLI, REST
* controller, and cron jobs. They are not part of the public API contract
* and may change or be removed without notice. Do not depend on them.
* controller, and cron jobs. Those marked `@access private` are not part of
* the public API contract and may change or be removed without notice.
*/

if ( ! function_exists( 'wp_presence_get_timeout' ) ) {
Expand Down Expand Up @@ -1955,11 +1959,11 @@ function wp_get_presence_summary( $timeout = null ) {

if ( ! function_exists( 'wp_delete_expired_presence_data' ) ) {
/**
* Deletes stale presence entries older than the default TTL.
* Deletes presence rows past their own expiry.
*
* Runs on the every-minute cron event. Rather than looping without a ceiling
* over the MySQL-only `DELETE ... LIMIT` construct, this selects a bounded
* page of primary keys older than the cutoff and deletes them by key, for a
* page of expired primary keys and deletes them by key, for a
* fixed number of passes per invocation. Any remaining backlog is left for the
* next cron run, so a single request cannot run until `max_execution_time`
* when a site returns from a cron outage with a large backlog. Deleting by
Expand Down Expand Up @@ -2180,9 +2184,8 @@ function wp_presence_hydrate_room_users( $rooms, $timeout = null ) {
/**
* Renders the small "Agent" badge shown next to an agent's presence row.
*
* Shared across every surface that lists a presence row by user — Who's
* Online, the admin bar, the Active Posts widget and the post list's
* Editors column — so an agent reads the same way wherever it shows up.
* The admin bar shows it beside each agent's name, while the Active Posts
* widget and the Editors column label agents in plain text.
*
* @access private
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ public function create_item( $request ) {
// 1. The UNIQUE KEY on (room, client_id) prevents duplicate rows.
// 2. Heartbeat re-establishes correct ownership within seconds.
// 3. Worst case: two users briefly share an entry until the next ping.
// 4. Data is ephemeral (150s default TTL) — stale entries self-correct.
// 4. Data is ephemeral (150s default TTL), so stale entries self-correct.
// Table-level locking is not warranted for ephemeral data.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$existing_user_id = $wpdb->get_var(
Expand Down Expand Up @@ -622,7 +622,7 @@ public function delete_item_permissions_check( $request ) {
)
);

// Entry doesn't exist — allow the delete (it's a no-op).
// Entry doesn't exist; allow the delete, which removes nothing.
if ( null === $entry_user_id ) {
return true;
}
Expand Down
19 changes: 10 additions & 9 deletions plugins/presence-api/includes/screen-revisions.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
* (post.php), user edits (user-edit.php, profile.php), term edits
* (edit-tags.php), comment edits (comment.php), and on multisite Network
* Settings and the Edit Site tabs. JS-driven and REST-driven
* screens can opt in via the `wp_presence_current_screen_key` filter plus a
* future client-side `markScreenStale()` API.
* screens can opt in via the `wp_presence_current_screen_key` filter plus the
* client-side `wp.presence.markScreenStale()`.
*
* @package Presence_API
*/
Expand All @@ -37,13 +37,14 @@
*
* Shape: array( '<screen_key>' => array( 'rev' => int, 'actor_id' => int, 'time' => int ) ).
* The actor's display name and avatar are looked up fresh on each heartbeat
* tick — not stored — so renames and avatar changes show immediately.
* tick rather than stored, so renames and avatar changes show immediately.
*
* Only screen keys that don't map to a post, user, term, comment, or a known
* Settings page land here — see wp_presence_parse_screen_key_target(). Those
* write straight to the object they describe instead of this shared option,
* so a save on one screen doesn't invalidate the cache entry for every other
* screen in the map.
* Only screen keys that don't map to a post, user, term, comment, a known
* Settings page or a network screen land here; see
* wp_presence_parse_screen_key_target(). The rest write to the object they
* describe, or network screens to a site option, instead of this shared
* option, so a save on one screen doesn't invalidate the cache entry for every
* other screen in the map.
*
* @since 0.1.3
*
Expand Down Expand Up @@ -187,7 +188,7 @@ function wp_presence_get_screen_revision( $screen_key ) {
return null;
}
// mysql2date( 'U', ... ) adds the site's timezone offset even when
// $translate is false — core's own get_post_time() skips that add
// $translate is false; core's own get_post_time() skips that add
// only for its $gmt branch. post_modified_gmt is already a naive
// UTC string, and WordPress forces PHP's default timezone to UTC
// at bootstrap, so a plain strtotime() is the correct conversion.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,13 @@ private static function summary_markup( $summary ) {
* tick costs one lookup here, never the capability check or the
* summary query.
*
* Nonce verification is handled by WordPress in wp_ajax_heartbeat().
*
* @since 0.2.0
*
* @param array $response The Heartbeat response.
* @param array $data The $_POST data sent.
* @param string $screen_id The screen ID.
* Nonce verification is handled by WordPress in wp_ajax_heartbeat().
*
* @return array The Heartbeat response.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ private static function list_markup( $posts ) {
break;
}
}
// Only show status when it differs — "Idle" is the signal.
// Only show status when it differs, since "Idle" is the signal.
// Active is the default state; labeling it adds noise.
$status_label = $any_active ? '' : __( 'Idle', 'presence-api' );

Expand Down Expand Up @@ -134,8 +134,8 @@ private static function list_markup( $posts ) {

/**
* Suffixes a display name to mark it as an agent's, everywhere this widget
* names one in plain text rather than through wp_presence_render_agent_badge()
* — every editor_label string is passed through esc_html(), which would
* names one in plain text rather than through wp_presence_render_agent_badge(),
* since every editor_label string is passed through esc_html(), which would
* strip that badge's markup.
*
* @since 0.9.0
Expand Down
5 changes: 2 additions & 3 deletions plugins/presence-api/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Presence room id (e.g. `postType/post:123`). When `null` or `undefined`, no requ
Returns an object with:

- `isPresent` `boolean` - Whether any users are present in the room.
- `isLoading` `boolean` - True until the first fetch completes. Use this to avoid flickering empty states.
- `isLoading` `boolean` - True until the first fetch completes, or false at once with no `room` or no Heartbeat. Use this to avoid flickering empty states.
- `users` `Array<Object>` - Array of user objects, each containing:
- `id` `number` - The user ID.
- `displayName` `string` - The user's display name.
Expand Down Expand Up @@ -112,8 +112,7 @@ Example live region pattern:

### Notes

- The hook automatically subscribes to WordPress Heartbeat's `heartbeat-tick` event to avoid duplicating network traffic.
- The hook polls on WordPress Heartbeat's `heartbeat-tick`, so it follows Heartbeat's schedule.
- Deduplicates users by ID.
- Prevents race conditions: if a heartbeat tick fires while a fetch is in progress, the second request is skipped.
- Coalesces polling across tabs and instances sharing a room and `fields`: one visible tab per site polls (via Web Locks, where available) and shares results with the rest over `BroadcastChannel`.
- In development mode, logs warnings and errors to the console for debugging.
7 changes: 3 additions & 4 deletions plugins/presence-api/src/utils/heartbeat-events.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
/**
* Heartbeat event utilities.
*
* WordPress core's heartbeat.js currently triggers events via jQuery.
* These utilities provide a forward-compatible abstraction that works
* with or without jQuery.
* WordPress core's heartbeat.js triggers events via jQuery. These utilities
* also listen for a native CustomEvent, which only the unit tests dispatch.
*
* @package Presence_API
*/
Expand Down Expand Up @@ -35,7 +34,7 @@ export function onHeartbeatTick( callback ) {
}

// Fallback: if jQuery isn't available, listen for native CustomEvent.
// This path is used in test environments and future jQuery-free WordPress.
// Only the unit tests reach this path, since core's Heartbeat needs jQuery.
const nativeCallback = ( event ) => {
callback( event.detail );
};
Expand Down
Loading