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