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

521 lines 16.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 * @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 * Pass-through to the option; cheap enough to call every Heartbeat
89 * tick. Fires `desktop_mode_presence_recorded` on every call and
90 * `desktop_mode_presence_changed` only when the computed status moves
91 * between `online | inactive | offline`.
92 *
93 * The `desktop_mode_presence_can_track` filter is the per-user opt-out:
94 * a plugin that hides specific accounts (compliance, "set yourself
95 * invisible", etc.) returns false to skip the bump entirely.
96 *
97 * @since 0.5.5
98 *
99 * @param int $user_id User to record.
100 * @param bool $active Pass `true` when the heartbeat is paired with
101 * explicit user activity (mousedown, keydown).
102 * @return bool True if recorded; false if vetoed by filter or invalid id.
103 */
104 function desktop_mode_presence_record( $user_id, $active = true ) {
105 $user_id = (int) $user_id;
106 if ( $user_id <= 0 ) {
107 return false;
108 }
109
110 /**
111 * Per-user veto on presence tracking. Return false to skip the
112 * bump entirely — useful for "appear offline" toggles, audit
113 * exemptions for sensitive accounts, or forcing a non-admin
114 * never-tracked policy.
115 *
116 * @since 0.5.5
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 $all[ $user_id ] = $next;
139 update_option( DESKTOP_MODE_PRESENCE_OPTION, $all, false );
140
141 $next_status = desktop_mode_presence_status_from_record( $next );
142
143 /**
144 * Fires on every recorded heartbeat — useful for audit logging
145 * or third-party "who's around right now" dashboards. Fires
146 * regardless of whether the computed status changed.
147 *
148 * @since 0.5.5
149 *
150 * @param int $user_id
151 * @param array $record { last_seen_ms, last_active_ms }
152 */
153 do_action( 'desktop_mode_presence_recorded', $user_id, $next );
154
155 if ( $next_status !== $prev_status ) {
156 /**
157 * Fires when a user's computed presence status transitions.
158 * Plugins driving "user came online / went offline" UI hook
159 * here — the recorded action above fires every tick whether
160 * the state changed or not, which would be too noisy.
161 *
162 * @since 0.5.5
163 *
164 * @param int $user_id
165 * @param string $new_status One of `online | inactive | offline`.
166 * @param string $old_status One of `online | inactive | offline`.
167 */
168 do_action( 'desktop_mode_presence_changed', $user_id, $next_status, $prev_status );
169 }
170 return true;
171 }
172
173 /**
174 * Compute presence status from a record.
175 *
176 * Pure function — given the same `(record, now)`, always returns
177 * the same answer. Filters override the thresholds, not the logic
178 * order; an `online` user transitions through `inactive` to
179 * `offline` if they're idle long enough.
180 *
181 * @since 0.5.5
182 *
183 * @param array $record { last_seen_ms?: int, last_active_ms?: int }
184 * @return string `online | inactive | offline`
185 */
186 function desktop_mode_presence_status_from_record( $record ) {
187 $now_ms = (int) round( microtime( true ) * 1000 );
188 $last_seen = isset( $record['last_seen_ms'] ) ? (int) $record['last_seen_ms'] : 0;
189 $last_active = isset( $record['last_active_ms'] ) ? (int) $record['last_active_ms'] : 0;
190
191 /**
192 * Inactive threshold (default 300s = 5 min). Online users
193 * transition to `inactive` when they haven't moused / typed
194 * for this long, even if Heartbeat keeps firing.
195 *
196 * @since 0.5.5
197 *
198 * @param int $seconds
199 */
200 $inactive_after = (int) apply_filters( 'desktop_mode_presence_inactive_after', 300 );
201
202 /**
203 * Offline threshold (default 120s = 2 min). Inactive / online
204 * users transition to `offline` when the last Heartbeat is
205 * older than this.
206 *
207 * @since 0.5.5
208 *
209 * @param int $seconds
210 */
211 $offline_after = (int) apply_filters( 'desktop_mode_presence_offline_after', 120 );
212
213 if ( $now_ms - $last_seen > $offline_after * 1000 ) {
214 return 'offline';
215 }
216 if ( $now_ms - $last_active > $inactive_after * 1000 ) {
217 return 'inactive';
218 }
219 return 'online';
220 }
221
222 /**
223 * Look up presence status for a single user.
224 *
225 * @since 0.5.5
226 *
227 * @param int $user_id
228 * @return string `online | inactive | offline`
229 */
230 function desktop_mode_presence_status_for_user( $user_id ) {
231 $all = desktop_mode_presence_get_all();
232 $record = isset( $all[ (int) $user_id ] ) ? $all[ (int) $user_id ] : array();
233 return desktop_mode_presence_status_from_record( (array) $record );
234 }
235
236 /**
237 * Build a presence snapshot. With `$user_ids = null` returns every
238 * tracked user; with a list returns only those ids (useful for
239 * "users I care about" filtering — e.g., a plugin that surfaces
240 * the subset of users relevant to the viewer).
241 *
242 * Output shape uses string keys so the JSON encoder produces an
243 * object (not a sparse array) when the smallest id isn't 1.
244 *
245 * @since 0.5.5
246 *
247 * @param int[]|null $user_ids Restrict to these ids. `null` = all.
248 * @return array<string,array{ status:string, lastSeenMs:int, lastActiveMs:int }>
249 */
250 function desktop_mode_presence_snapshot( $user_ids = null ) {
251 $all = desktop_mode_presence_get_all();
252 $out = array();
253
254 if ( null === $user_ids ) {
255 $ids = array_keys( $all );
256 } else {
257 $ids = array();
258 foreach ( (array) $user_ids as $uid ) {
259 $uid = (int) $uid;
260 if ( $uid > 0 ) {
261 $ids[] = $uid;
262 }
263 }
264 }
265
266 foreach ( $ids as $uid ) {
267 $record = isset( $all[ $uid ] ) ? $all[ $uid ] : array();
268 $out[ (string) $uid ] = array(
269 'status' => desktop_mode_presence_status_from_record( $record ),
270 'lastSeenMs' => isset( $record['last_seen_ms'] ) ? (int) $record['last_seen_ms'] : 0,
271 'lastActiveMs' => isset( $record['last_active_ms'] ) ? (int) $record['last_active_ms'] : 0,
272 );
273 }
274 return $out;
275 }
276
277 /**
278 * Filter a list of candidate user ids down to those a given viewer
279 * is allowed to see presence for. Defaults to passing the list
280 * through unchanged — plugins implementing per-team / per-role
281 * privacy boundaries hook `desktop_mode_presence_visible_users`
282 * (e.g., "subscribers can only see other subscribers' presence").
283 *
284 * @since 0.5.5
285 *
286 * @param int[] $candidate_user_ids
287 * @param int $viewer_id Defaults to the current user.
288 * @return int[]
289 */
290 function desktop_mode_presence_visible_users( $candidate_user_ids, $viewer_id = 0 ) {
291 $viewer_id = (int) $viewer_id ?: get_current_user_id();
292 $ids = array();
293 foreach ( (array) $candidate_user_ids as $uid ) {
294 $uid = (int) $uid;
295 if ( $uid > 0 ) {
296 $ids[] = $uid;
297 }
298 }
299 $ids = array_values( array_unique( $ids ) );
300
301 /**
302 * Filter the list of user ids whose presence is visible to
303 * `$viewer_id`. Default behaviour: all candidates pass. Hook
304 * to enforce privacy — e.g., subscribers only see other
305 * subscribers; admins see everyone; an opt-out list never shows.
306 *
307 * @since 0.5.5
308 *
309 * @param int[] $ids Candidate user ids.
310 * @param int $viewer_id The user requesting visibility.
311 */
312 return (array) apply_filters( 'desktop_mode_presence_visible_users', $ids, $viewer_id );
313 }
314
315 /**
316 * Daily cron: prune presence entries for users idle >14 days.
317 * Keeps the option compact even on long-running sites.
318 *
319 * @since 0.5.5
320 */
321 function desktop_mode_presence_cron_prune() {
322 $all = desktop_mode_presence_get_all();
323 if ( empty( $all ) ) {
324 return;
325 }
326 $threshold = (int) round( microtime( true ) * 1000 ) - ( 14 * DAY_IN_SECONDS * 1000 );
327 $pruned = array();
328 foreach ( $all as $uid => $record ) {
329 if ( ( (int) $record['last_seen_ms'] ) < $threshold ) {
330 continue;
331 }
332 $pruned[ (int) $uid ] = $record;
333 }
334 if ( count( $pruned ) !== count( $all ) ) {
335 update_option( DESKTOP_MODE_PRESENCE_OPTION, $pruned, false );
336 }
337 }
338 add_action( 'desktop_mode_presence_daily_prune', 'desktop_mode_presence_cron_prune' );
339
340 /**
341 * Schedule the daily cron once. Idempotent.
342 *
343 * @since 0.5.5
344 */
345 function desktop_mode_presence_schedule_cron() {
346 if ( ! wp_next_scheduled( 'desktop_mode_presence_daily_prune' ) ) {
347 wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'desktop_mode_presence_daily_prune' );
348 }
349 }
350 add_action( 'init', 'desktop_mode_presence_schedule_cron', 50 );
351
352 /* -------------------------------------------------------------------------
353 * Heartbeat integration
354 * ----------------------------------------------------------------------- */
355
356 /**
357 * Heartbeat handler — bumps presence on every tick a desktop-mode
358 * user is on the page. Returns the visible-presence snapshot in
359 * the response so the client store can update without a separate
360 * REST round-trip.
361 *
362 * Triggered by the client opting in via `desktop_mode_presence_active:
363 * true` in the heartbeat-send payload, with optional
364 * `desktop_mode_user_active` (mousedown / keydown within the
365 * inactive-threshold window).
366 *
367 * @since 0.5.5
368 *
369 * @param array $response Pre-filtered response.
370 * @param array $data Client-sent payload.
371 * @return array
372 */
373 function desktop_mode_presence_heartbeat_received( $response, $data ) {
374 if ( ! is_array( $response ) ) {
375 $response = array();
376 }
377 if ( empty( $data['desktop_mode_presence_active'] ) ) {
378 return $response;
379 }
380 if ( ! function_exists( 'desktop_mode_is_enabled' ) || ! desktop_mode_is_enabled() ) {
381 return $response;
382 }
383 $user_id = (int) get_current_user_id();
384 $user_active = ! empty( $data['desktop_mode_user_active'] );
385
386 desktop_mode_presence_record( $user_id, $user_active );
387
388 // Snapshot the users this viewer is allowed to see — by default
389 // all tracked users; plugins can narrow via the
390 // `desktop_mode_presence_visible_users` filter.
391 $all_ids = array_keys( desktop_mode_presence_get_all() );
392 $visible = desktop_mode_presence_visible_users( $all_ids, $user_id );
393
394 $response['desktop_mode_presence'] = array(
395 'snapshot' => desktop_mode_presence_snapshot( $visible ),
396 'serverTimeMs' => (int) round( microtime( true ) * 1000 ),
397 );
398 return $response;
399 }
400 add_filter( 'heartbeat_received', 'desktop_mode_presence_heartbeat_received', 5, 2 );
401
402 /* -------------------------------------------------------------------------
403 * REST endpoints
404 * ----------------------------------------------------------------------- */
405
406 /**
407 * Permission gate for presence endpoints — login required +
408 * desktop mode enabled.
409 *
410 * @since 0.5.5
411 *
412 * @return bool|WP_Error
413 */
414 function desktop_mode_presence_rest_permission() {
415 if ( ! is_user_logged_in() ) {
416 return new WP_Error(
417 'rest_forbidden',
418 __( 'Authentication required.', 'desktop-mode' ),
419 array( 'status' => 401 )
420 );
421 }
422 if ( ! function_exists( 'desktop_mode_is_enabled' ) || ! desktop_mode_is_enabled() ) {
423 return new WP_Error(
424 'rest_forbidden',
425 __( 'Desktop mode is not enabled for your account.', 'desktop-mode' ),
426 array( 'status' => 403 )
427 );
428 }
429 return true;
430 }
431
432 /**
433 * Register `/desktop-mode/v1/presence` routes.
434 *
435 * @since 0.5.5
436 */
437 function desktop_mode_presence_register_rest_routes() {
438 register_rest_route(
439 'desktop-mode/v1',
440 '/presence',
441 array(
442 array(
443 'methods' => WP_REST_Server::READABLE,
444 'permission_callback' => 'desktop_mode_presence_rest_permission',
445 'callback' => 'desktop_mode_presence_rest_get',
446 ),
447 array(
448 'methods' => WP_REST_Server::CREATABLE,
449 'permission_callback' => 'desktop_mode_presence_rest_permission',
450 'callback' => 'desktop_mode_presence_rest_post',
451 'args' => array(
452 'active' => array( 'type' => 'boolean' ),
453 'inactive' => array( 'type' => 'boolean' ),
454 ),
455 ),
456 )
457 );
458 }
459 add_action( 'rest_api_init', 'desktop_mode_presence_register_rest_routes' );
460
461 /**
462 * GET /desktop-mode/v1/presence — current snapshot, narrowed by the
463 * visibility filter.
464 */
465 function desktop_mode_presence_rest_get() {
466 $viewer_id = (int) get_current_user_id();
467 $all_ids = array_keys( desktop_mode_presence_get_all() );
468 $visible = desktop_mode_presence_visible_users( $all_ids, $viewer_id );
469 return rest_ensure_response(
470 array(
471 'snapshot' => desktop_mode_presence_snapshot( $visible ),
472 'serverTimeMs' => (int) round( microtime( true ) * 1000 ),
473 )
474 );
475 }
476
477 /**
478 * POST /desktop-mode/v1/presence — explicit bump. Body shape:
479 *
480 * - `{ active: true }` → bump both seen + active timestamps.
481 * - `{ active: false }` → bump seen only (window in background).
482 * - `{ inactive: true }` → bump seen only AND zero active so the
483 * user lands on `inactive` immediately
484 * (the "set yourself away" UI hook).
485 *
486 * Defaults to `{ active: true }` when neither flag is supplied —
487 * the simplest "I'm here" call.
488 */
489 function desktop_mode_presence_rest_post( WP_REST_Request $request ) {
490 $user_id = (int) get_current_user_id();
491 $active = $request->get_param( 'active' );
492 $inactive = (bool) $request->get_param( 'inactive' );
493
494 if ( $inactive ) {
495 // Set the user immediately to `inactive`: bump last_seen
496 // (still alive) but force last_active to zero (no recent
497 // interaction).
498 $all = desktop_mode_presence_get_all();
499 $rec = isset( $all[ $user_id ] ) ? $all[ $user_id ] : array(
500 'last_seen_ms' => 0,
501 'last_active_ms' => 0,
502 );
503 $prev_status = desktop_mode_presence_status_from_record( $rec );
504 $rec['last_seen_ms'] = (int) round( microtime( true ) * 1000 );
505 $rec['last_active_ms'] = 0;
506 $all[ $user_id ] = $rec;
507 update_option( DESKTOP_MODE_PRESENCE_OPTION, $all, false );
508
509 $next_status = desktop_mode_presence_status_from_record( $rec );
510 do_action( 'desktop_mode_presence_recorded', $user_id, $rec );
511 if ( $next_status !== $prev_status ) {
512 do_action( 'desktop_mode_presence_changed', $user_id, $next_status, $prev_status );
513 }
514 } else {
515 $flag = ( null === $active ) ? true : (bool) $active;
516 desktop_mode_presence_record( $user_id, $flag );
517 }
518
519 return rest_ensure_response( array( 'ok' => true ) );
520 }
521