PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
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 0.8.6 All 33 releases
desktop-mode / includes / presence.php

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

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