PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / presence.php

presence.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/presence.php

532 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 defined( 'ABSPATH' ) || exit;
3 /**
4 * Desktop Mode — framework-level presence.
5 *
6 * Tracks who's currently in the desktop-mode WP-Admin and what
7 * their state is — `online`, `inactive`, `offline`. Lives at
8 * framework level so any plugin can consume presence without
9 * depending on chat / collaboration / co-editing features being
10 * enabled.
11 *
12 * **State machine.** Three values, derived from two timestamps:
13 *
14 * - **online** — Heartbeat seen within `_offline_after` seconds
15 * AND user activity (mousedown / keydown) within
16 * `_inactive_after` seconds (default 300s = 5 min).
17 * - **inactive** — Heartbeat seen within `_offline_after` but no
18 * user activity within `_inactive_after`.
19 * - **offline** — no Heartbeat in `_offline_after` (default 120s).
20 *
21 * Storage is a single autoload=false option (`_desktop_mode_presence`)
22 * shaped `array<int user_id, array{ last_seen_ms, last_active_ms }>`.
23 * Single-row keeps autoload happy and avoids per-user options.
24 *
25 * **Public surface.** PHP helpers:
26 *
27 * - `desktop_mode_presence_record( $user_id, $active )`
28 * - `desktop_mode_presence_status_for_user( $user_id )`
29 * - `desktop_mode_presence_get_all()`
30 * - `desktop_mode_presence_snapshot( $user_ids = null )`
31 *
32 * Filters:
33 *
34 * - `desktop_mode_presence_inactive_after` — int seconds. Default 300.
35 * - `desktop_mode_presence_offline_after` — int seconds. Default 120.
36 * - `desktop_mode_presence_can_track` — bool, $user_id. Veto.
37 * - `desktop_mode_presence_visible_users` — int[], $viewer_id.
38 * Privacy gate for who's
39 * surfaced to a given user.
40 *
41 * Actions:
42 *
43 * - `desktop_mode_presence_recorded( $user_id, $record )` — on every
44 * bump.
45 * - `desktop_mode_presence_changed( $user_id, $new, $old )` — on
46 * state transitions only.
47 *
48 * REST: `/desktop-mode/v1/presence` (GET snapshot, POST mark active /
49 * inactive).
50 *
51 * @package WPDesktopMode
52 */
53
54 const DESKTOP_MODE_PRESENCE_OPTION = '_desktop_mode_presence';
55
56 /**
57 * Read the entire presence map. Single autoload=false option.
58 *
59 * @return array<int,array{last_seen_ms:int,last_active_ms:int}>
60 */
61 function desktop_mode_presence_get_all() {
62 $raw = get_option( DESKTOP_MODE_PRESENCE_OPTION, array() );
63 if ( ! is_array( $raw ) ) {
64 return array();
65 }
66 $out = array();
67 foreach ( $raw as $uid => $record ) {
68 $uid = (int) $uid;
69 if ( $uid <= 0 || ! is_array( $record ) ) {
70 continue;
71 }
72 $out[ $uid ] = array(
73 'last_seen_ms' => isset( $record['last_seen_ms'] ) ? (int) $record['last_seen_ms'] : 0,
74 'last_active_ms' => isset( $record['last_active_ms'] ) ? (int) $record['last_active_ms'] : 0,
75 );
76 }
77 return $out;
78 }
79
80 /**
81 * Record a "user is alive" heartbeat. Bumps `last_seen_ms`. If
82 * `$active` is true, also bumps `last_active_ms` (the user just
83 * interacted, not just held a tab open).
84 *
85 * Cheap enough to call every Heartbeat tick: the option write is
86 * throttled — a bump that neither transitions the computed status
87 * nor moves a persisted timestamp by at least half the offline
88 * threshold (capped at 60s) skips the `update_option()` call, so N
89 * idle users no longer rewrite the shared row every tick. Persisted
90 * timestamps can therefore lag real activity by up to the throttle
91 * window — always well inside the offline threshold, so computed
92 * statuses stay correct. Fires `desktop_mode_presence_recorded` on
93 * every call (with the fresh, un-throttled record) and
94 * `desktop_mode_presence_changed` only when the computed status moves
95 * between `online | inactive | offline`.
96 *
97 * The `desktop_mode_presence_can_track` filter is the per-user opt-out:
98 * a plugin that hides specific accounts (compliance, "set yourself
99 * invisible", etc.) returns false to skip the bump entirely.
100 *
101 * @param int $user_id User to record.
102 * @param bool $active Pass `true` when the heartbeat is paired with
103 * explicit user activity (mousedown, keydown).
104 * @return bool True if recorded; false if vetoed by filter or invalid id.
105 */
106 function desktop_mode_presence_record( $user_id, $active = true ) {
107 $user_id = (int) $user_id;
108 if ( $user_id <= 0 ) {
109 return false;
110 }
111
112 /**
113 * Per-user veto on presence tracking. Return false to skip the
114 * bump entirely — useful for "appear offline" toggles, audit
115 * exemptions for sensitive accounts, or forcing a non-admin
116 * never-tracked policy.
117 *
118 * @param bool $can Default true.
119 * @param int $user_id The user being tracked.
120 */
121 $can = (bool) apply_filters( 'desktop_mode_presence_can_track', true, $user_id );
122 if ( ! $can ) {
123 return false;
124 }
125
126 $now_ms = (int) round( microtime( true ) * 1000 );
127 $all = desktop_mode_presence_get_all();
128 $prev = isset( $all[ $user_id ] ) ? $all[ $user_id ] : array(
129 'last_seen_ms' => 0,
130 'last_active_ms' => 0,
131 );
132 $prev_status = desktop_mode_presence_status_from_record( $prev );
133
134 $next = array(
135 'last_seen_ms' => $now_ms,
136 'last_active_ms' => $active ? $now_ms : (int) $prev['last_active_ms'],
137 );
138
139 $next_status = desktop_mode_presence_status_from_record( $next );
140
141 if ( desktop_mode_presence_should_persist( $all, $user_id, $prev, $prev_status, $next_status, $active, $now_ms ) ) {
142 $all[ $user_id ] = $next;
143 update_option( DESKTOP_MODE_PRESENCE_OPTION, $all, false );
144 }
145
146 /**
147 * Fires on every recorded heartbeat — useful for audit logging
148 * or third-party "who's around right now" dashboards. Fires
149 * regardless of whether the computed status changed.
150 *
151 * @param int $user_id
152 * @param array $record { last_seen_ms, last_active_ms }
153 */
154 do_action( 'desktop_mode_presence_recorded', $user_id, $next );
155
156 if ( $next_status !== $prev_status ) {
157 /**
158 * Fires when a user's computed presence status transitions.
159 * Plugins driving "user came online / went offline" UI hook
160 * here — the recorded action above fires every tick whether
161 * the state changed or not, which would be too noisy.
162 *
163 * @param int $user_id
164 * @param string $new_status One of `online | inactive | offline`.
165 * @param string $old_status One of `online | inactive | offline`.
166 */
167 do_action( 'desktop_mode_presence_changed', $user_id, $next_status, $prev_status );
168 }
169 return true;
170 }
171
172 /**
173 * Decide whether a presence bump needs to hit the database.
174 *
175 * The presence map is a single shared option row: with N concurrent
176 * users an unconditional write per Heartbeat tick means N full-row
177 * rewrites (plus option-cache invalidations) every ~15s, almost all
178 * of them recording no meaningful change. A bump must persist when:
179 *
180 * - the user isn't in the map yet (first sighting),
181 * - the computed status transitioned (viewers must see it), or
182 * - a persisted timestamp has drifted by at least the throttle
183 * window — half the offline threshold, capped at 60s — so stored
184 * `last_seen_ms` can never age anywhere near the offline cutoff
185 * while the user is genuinely present.
186 *
187 * Everything else is a redundant rewrite and is skipped. Skipped
188 * bumps still fire `desktop_mode_presence_recorded` with the fresh
189 * record — only the persisted copy lags.
190 *
191 * @param array $all Stored presence map.
192 * @param int $user_id User being bumped.
193 * @param array $prev Stored record for the user (zeros if new).
194 * @param string $prev_status Status computed from the stored record.
195 * @param string $next_status Status computed from the fresh record.
196 * @param bool $active Whether this bump carries user activity.
197 * @param int $now_ms Current epoch milliseconds.
198 * @return bool True to persist, false to skip the write.
199 */
200 function desktop_mode_presence_should_persist( $all, $user_id, $prev, $prev_status, $next_status, $active, $now_ms ) {
201 if ( ! isset( $all[ $user_id ] ) ) {
202 return true;
203 }
204 if ( $next_status !== $prev_status ) {
205 return true;
206 }
207
208 /** This filter is documented in includes/presence.php */
209 $offline_after = (int) apply_filters( 'desktop_mode_presence_offline_after', 120 );
210 $throttle_ms = (int) min( 60 * 1000, $offline_after * 500 );
211
212 if ( ( $now_ms - (int) $prev['last_seen_ms'] ) >= $throttle_ms ) {
213 return true;
214 }
215 if ( $active && ( $now_ms - (int) $prev['last_active_ms'] ) >= $throttle_ms ) {
216 return true;
217 }
218 return false;
219 }
220
221 /**
222 * Compute presence status from a record.
223 *
224 * Pure function — given the same `(record, now)`, always returns
225 * the same answer. Filters override the thresholds, not the logic
226 * order; an `online` user transitions through `inactive` to
227 * `offline` if they're idle long enough.
228 *
229 * @param array $record { last_seen_ms?: int, last_active_ms?: int }
230 * @return string `online | inactive | offline`
231 */
232 function desktop_mode_presence_status_from_record( $record ) {
233 $now_ms = (int) round( microtime( true ) * 1000 );
234 $last_seen = isset( $record['last_seen_ms'] ) ? (int) $record['last_seen_ms'] : 0;
235 $last_active = isset( $record['last_active_ms'] ) ? (int) $record['last_active_ms'] : 0;
236
237 /**
238 * Inactive threshold (default 300s = 5 min). Online users
239 * transition to `inactive` when they haven't moused / typed
240 * for this long, even if Heartbeat keeps firing.
241 *
242 * @param int $seconds
243 */
244 $inactive_after = (int) apply_filters( 'desktop_mode_presence_inactive_after', 300 );
245
246 /**
247 * Offline threshold (default 120s = 2 min). Inactive / online
248 * users transition to `offline` when the last Heartbeat is
249 * older than this.
250 *
251 * @param int $seconds
252 */
253 $offline_after = (int) apply_filters( 'desktop_mode_presence_offline_after', 120 );
254
255 if ( $now_ms - $last_seen > $offline_after * 1000 ) {
256 return 'offline';
257 }
258 if ( $now_ms - $last_active > $inactive_after * 1000 ) {
259 return 'inactive';
260 }
261 return 'online';
262 }
263
264 /**
265 * Look up presence status for a single user.
266 *
267 * @param int $user_id
268 * @return string `online | inactive | offline`
269 */
270 function desktop_mode_presence_status_for_user( $user_id ) {
271 $all = desktop_mode_presence_get_all();
272 $record = isset( $all[ (int) $user_id ] ) ? $all[ (int) $user_id ] : array();
273 return desktop_mode_presence_status_from_record( (array) $record );
274 }
275
276 /**
277 * Build a presence snapshot. With `$user_ids = null` returns every
278 * tracked user; with a list returns only those ids (useful for
279 * "users I care about" filtering — e.g., a plugin that surfaces
280 * the subset of users relevant to the viewer).
281 *
282 * Output shape uses string keys so the JSON encoder produces an
283 * object (not a sparse array) when the smallest id isn't 1.
284 *
285 * @param int[]|null $user_ids Restrict to these ids. `null` = all.
286 * @return array<string,array{ status:string, lastSeenMs:int, lastActiveMs:int }>
287 */
288 function desktop_mode_presence_snapshot( $user_ids = null ) {
289 $all = desktop_mode_presence_get_all();
290 $out = array();
291
292 if ( null === $user_ids ) {
293 $ids = array_keys( $all );
294 } else {
295 $ids = array();
296 foreach ( (array) $user_ids as $uid ) {
297 $uid = (int) $uid;
298 if ( $uid > 0 ) {
299 $ids[] = $uid;
300 }
301 }
302 }
303
304 foreach ( $ids as $uid ) {
305 $record = isset( $all[ $uid ] ) ? $all[ $uid ] : array();
306 $out[ (string) $uid ] = array(
307 'status' => desktop_mode_presence_status_from_record( $record ),
308 'lastSeenMs' => isset( $record['last_seen_ms'] ) ? (int) $record['last_seen_ms'] : 0,
309 'lastActiveMs' => isset( $record['last_active_ms'] ) ? (int) $record['last_active_ms'] : 0,
310 );
311 }
312 return $out;
313 }
314
315 /**
316 * Filter a list of candidate user ids down to those a given viewer
317 * is allowed to see presence for. Defaults to passing the list
318 * through unchanged — plugins implementing per-team / per-role
319 * privacy boundaries hook `desktop_mode_presence_visible_users`
320 * (e.g., "subscribers can only see other subscribers' presence").
321 *
322 * @param int[] $candidate_user_ids
323 * @param int $viewer_id Defaults to the current user.
324 * @return int[]
325 */
326 function desktop_mode_presence_visible_users( $candidate_user_ids, $viewer_id = 0 ) {
327 $viewer_id = (int) $viewer_id ?: get_current_user_id();
328 $ids = array();
329 foreach ( (array) $candidate_user_ids as $uid ) {
330 $uid = (int) $uid;
331 if ( $uid > 0 ) {
332 $ids[] = $uid;
333 }
334 }
335 $ids = array_values( array_unique( $ids ) );
336
337 /**
338 * Filter the list of user ids whose presence is visible to
339 * `$viewer_id`. Default behaviour: all candidates pass. Hook
340 * to enforce privacy — e.g., subscribers only see other
341 * subscribers; admins see everyone; an opt-out list never shows.
342 *
343 * @param int[] $ids Candidate user ids.
344 * @param int $viewer_id The user requesting visibility.
345 */
346 return (array) apply_filters( 'desktop_mode_presence_visible_users', $ids, $viewer_id );
347 }
348
349 /**
350 * Daily cron: prune presence entries for users idle >14 days.
351 * Keeps the option compact even on long-running sites.
352 */
353 function desktop_mode_presence_cron_prune() {
354 $all = desktop_mode_presence_get_all();
355 if ( empty( $all ) ) {
356 return;
357 }
358 $threshold = (int) round( microtime( true ) * 1000 ) - ( 14 * DAY_IN_SECONDS * 1000 );
359 $pruned = array();
360 foreach ( $all as $uid => $record ) {
361 if ( ( (int) $record['last_seen_ms'] ) < $threshold ) {
362 continue;
363 }
364 $pruned[ (int) $uid ] = $record;
365 }
366 if ( count( $pruned ) !== count( $all ) ) {
367 update_option( DESKTOP_MODE_PRESENCE_OPTION, $pruned, false );
368 }
369 }
370 add_action( 'desktop_mode_presence_daily_prune', 'desktop_mode_presence_cron_prune' );
371
372 /**
373 * Schedule the daily cron once. Idempotent.
374 */
375 function desktop_mode_presence_schedule_cron() {
376 if ( ! wp_next_scheduled( 'desktop_mode_presence_daily_prune' ) ) {
377 wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'desktop_mode_presence_daily_prune' );
378 }
379 }
380 add_action( 'init', 'desktop_mode_presence_schedule_cron', 50 );
381
382 /* -------------------------------------------------------------------------
383 * Heartbeat integration
384 * ----------------------------------------------------------------------- */
385
386 /**
387 * Heartbeat handler — bumps presence on every tick a desktop-mode
388 * user is on the page. Returns the visible-presence snapshot in
389 * the response so the client store can update without a separate
390 * REST round-trip.
391 *
392 * Triggered by the client opting in via `desktop_mode_presence_active:
393 * true` in the heartbeat-send payload, with optional
394 * `desktop_mode_user_active` (mousedown / keydown within the
395 * inactive-threshold window).
396 *
397 * @param array $response Pre-filtered response.
398 * @param array $data Client-sent payload.
399 * @return array
400 */
401 function desktop_mode_presence_heartbeat_received( $response, $data ) {
402 if ( ! is_array( $response ) ) {
403 $response = array();
404 }
405 if ( empty( $data['desktop_mode_presence_active'] ) ) {
406 return $response;
407 }
408 if ( ! function_exists( 'desktop_mode_is_enabled' ) || ! desktop_mode_is_enabled() ) {
409 return $response;
410 }
411 $user_id = (int) get_current_user_id();
412 $user_active = ! empty( $data['desktop_mode_user_active'] );
413
414 desktop_mode_presence_record( $user_id, $user_active );
415
416 // Snapshot the users this viewer is allowed to see — by default
417 // all tracked users; plugins can narrow via the
418 // `desktop_mode_presence_visible_users` filter.
419 $all_ids = array_keys( desktop_mode_presence_get_all() );
420 $visible = desktop_mode_presence_visible_users( $all_ids, $user_id );
421
422 $response['desktop_mode_presence'] = array(
423 'snapshot' => desktop_mode_presence_snapshot( $visible ),
424 'serverTimeMs' => (int) round( microtime( true ) * 1000 ),
425 );
426 return $response;
427 }
428 add_filter( 'heartbeat_received', 'desktop_mode_presence_heartbeat_received', 5, 2 );
429
430 /* -------------------------------------------------------------------------
431 * REST endpoints
432 * ----------------------------------------------------------------------- */
433
434 /**
435 * Permission gate for presence endpoints — login required +
436 * desktop mode enabled. Delegates to the shared
437 * {@see desktop_mode_rest_require_enabled()} gate.
438 *
439 * @return true|WP_Error
440 */
441 function desktop_mode_presence_rest_permission() {
442 return desktop_mode_rest_require_enabled();
443 }
444
445 /**
446 * Register `/desktop-mode/v1/presence` routes.
447 */
448 function desktop_mode_presence_register_rest_routes() {
449 register_rest_route(
450 'desktop-mode/v1',
451 '/presence',
452 array(
453 array(
454 'methods' => WP_REST_Server::READABLE,
455 'permission_callback' => 'desktop_mode_presence_rest_permission',
456 'callback' => 'desktop_mode_presence_rest_get',
457 ),
458 array(
459 'methods' => WP_REST_Server::CREATABLE,
460 'permission_callback' => 'desktop_mode_presence_rest_permission',
461 'callback' => 'desktop_mode_presence_rest_post',
462 'args' => array(
463 'active' => array( 'type' => 'boolean' ),
464 'inactive' => array( 'type' => 'boolean' ),
465 ),
466 ),
467 )
468 );
469 }
470 add_action( 'rest_api_init', 'desktop_mode_presence_register_rest_routes' );
471
472 /**
473 * GET /desktop-mode/v1/presence — current snapshot, narrowed by the
474 * visibility filter.
475 */
476 function desktop_mode_presence_rest_get() {
477 $viewer_id = (int) get_current_user_id();
478 $all_ids = array_keys( desktop_mode_presence_get_all() );
479 $visible = desktop_mode_presence_visible_users( $all_ids, $viewer_id );
480 return rest_ensure_response(
481 array(
482 'snapshot' => desktop_mode_presence_snapshot( $visible ),
483 'serverTimeMs' => (int) round( microtime( true ) * 1000 ),
484 )
485 );
486 }
487
488 /**
489 * POST /desktop-mode/v1/presence — explicit bump. Body shape:
490 *
491 * - `{ active: true }` → bump both seen + active timestamps.
492 * - `{ active: false }` → bump seen only (window in background).
493 * - `{ inactive: true }` → bump seen only AND zero active so the
494 * user lands on `inactive` immediately
495 * (the "set yourself away" UI hook).
496 *
497 * Defaults to `{ active: true }` when neither flag is supplied —
498 * the simplest "I'm here" call.
499 */
500 function desktop_mode_presence_rest_post( WP_REST_Request $request ) {
501 $user_id = (int) get_current_user_id();
502 $active = $request->get_param( 'active' );
503 $inactive = (bool) $request->get_param( 'inactive' );
504
505 if ( $inactive ) {
506 // Set the user immediately to `inactive`: bump last_seen
507 // (still alive) but force last_active to zero (no recent
508 // interaction).
509 $all = desktop_mode_presence_get_all();
510 $rec = isset( $all[ $user_id ] ) ? $all[ $user_id ] : array(
511 'last_seen_ms' => 0,
512 'last_active_ms' => 0,
513 );
514 $prev_status = desktop_mode_presence_status_from_record( $rec );
515 $rec['last_seen_ms'] = (int) round( microtime( true ) * 1000 );
516 $rec['last_active_ms'] = 0;
517 $all[ $user_id ] = $rec;
518 update_option( DESKTOP_MODE_PRESENCE_OPTION, $all, false );
519
520 $next_status = desktop_mode_presence_status_from_record( $rec );
521 do_action( 'desktop_mode_presence_recorded', $user_id, $rec );
522 if ( $next_status !== $prev_status ) {
523 do_action( 'desktop_mode_presence_changed', $user_id, $next_status, $prev_status );
524 }
525 } else {
526 $flag = ( null === $active ) ? true : (bool) $active;
527 desktop_mode_presence_record( $user_id, $flag );
528 }
529
530 return rest_ensure_response( array( 'ok' => true ) );
531 }
532