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

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