PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
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.7, at includes/presence.php

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