PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
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 / recycle-bin / realtime.php

realtime.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.4, at includes/recycle-bin/realtime.php

348 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Recycle Bin: real-time signal layer.
4 *
5 * Two non-polling paths feed the open Recycle Bin window:
6 *
7 * 1. **Fast path — chromeless iframe**
8 * Every recycle-bin-relevant action (`wp_trash_post`,
9 * `untrash_post`, `before_delete_post`, plus our four
10 * `openstation_recycle_bin_*` actions) flips a per-request
11 * static flag. At `admin_footer`, if the request is chromeless
12 * AND the flag is set, we emit a 12-line inline script that
13 * `postMessage`s the parent shell with `type:
14 * 'os-recycle-bin-changed'`. The parent dispatches our
15 * `CustomEvent`, the open window refreshes. Cost: ~zero unless
16 * a delete actually happened in this request. The per-domain
17 * `os.<type>.changed` list-refresh broadcasts ride the
18 * generic content-changes emitter instead (the changelog below
19 * delegates into `includes/content-changes.php`).
20 *
21 * 2. **Catch-all path — Heartbeat**
22 * Every delete also bumps a single autoload=false option
23 * `_desktop_mode_recycle_bin_change_ts` (a millisecond timestamp).
24 * The Heartbeat `heartbeat_received` filter checks the client's
25 * last-seen ts — if the option is newer, the response includes
26 * `openstation_recycle_bin: { changed, ts }`. The bin only subscribes
27 * while its window is open, so users without the bin open pay
28 * zero. The cost per tick is one cached option read.
29 *
30 * Why two paths: chromeless iframes that produce a footer (form
31 * POST → redirect → re-render, the dominant pattern for "Move to
32 * Trash" buttons) get instant updates. Everything else (AJAX list
33 * actions, REST `DELETE`, other browser tabs, WP-CLI, cron) drips
34 * in within the heartbeat cadence (15s active, 60s away).
35 *
36 * @package OpenStation
37 */
38
39 defined( 'ABSPATH' ) || exit;
40
41 /**
42 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
43 * persisted or externally-visible identifier, so renaming it would
44 * orphan data already written by live installs (or break a live
45 * URL). The mismatch between this constant's name and its value is
46 * deliberate — it is NOT a half-finished rename.
47 */
48 const OPENSTATION_RECYCLE_BIN_CHANGE_OPTION = '_desktop_mode_recycle_bin_change_ts';
49
50 /**
51 * Per-request "did this request trigger a recycle-bin change" flag.
52 *
53 * Backed by a function-static so it survives across hook callbacks
54 * within the same PHP request. Reading without args returns the
55 * current value; passing `true` sets it.
56 *
57 * @param bool|null $set Set the flag.
58 * @return bool
59 */
60 function openstation_recycle_bin_request_dirty( $set = null ) {
61 static $dirty = false;
62 if ( null !== $set ) {
63 $dirty = (bool) $set;
64 }
65 return $dirty;
66 }
67
68 /**
69 * Bump the global change timestamp + flip the per-request flag.
70 *
71 * Called from every recycle-bin-relevant hook. The timestamp is a
72 * milliseconds-since-epoch integer so client comparisons are
73 * straightforward and we don't need locale/timezone parsing.
74 *
75 * Stored as autoload=false to keep the option out of the always-
76 * loaded options query — recycle-bin polling is a "you opened the
77 * window, you opted in" cost, not a per-pageload cost.
78 */
79 function openstation_recycle_bin_signal_change() {
80 $ts = (int) round( microtime( true ) * 1000 );
81 update_option( OPENSTATION_RECYCLE_BIN_CHANGE_OPTION, $ts, false );
82 openstation_recycle_bin_request_dirty( true );
83
84 /**
85 * Fires after the recycle bin's "something changed" signal is
86 * bumped. Subscribers can use this to push their own real-time
87 * signal (websocket, SSE, etc.) without re-hooking every delete
88 * action individually.
89 *
90 * @param int $ts Milliseconds-since-epoch timestamp of the change.
91 */
92 do_action( 'openstation_recycle_bin_signal', $ts );
93 }
94
95 /**
96 * Wrapper for `wp_trash_post`-style actions that pass `$post_id`.
97 *
98 * Captures the post id + post type into the per-request changelog
99 * so the chromeless footer can emit one `os-broadcast`
100 * postMessage per affected domain (e.g. one for `post`, one for
101 * `attachment`, …). Subscribers — the recycle bin window, plus
102 * any plugin that registered a domain listener — react.
103 *
104 * @param int $post_id Post id being mutated.
105 * @param string $action One of 'trashed', 'untrashed', 'deleted'.
106 */
107 function openstation_recycle_bin_signal_change_for_post( $post_id, $action = 'trashed' ) {
108 $post = get_post( $post_id );
109 if ( $post instanceof WP_Post ) {
110 openstation_recycle_bin_record_change( (string) $post->post_type, (int) $post_id, (string) $action );
111 }
112 openstation_recycle_bin_signal_change();
113 }
114
115 /**
116 * Per-request changelog: `[ post_type ][ action ] = int[] ids`.
117 *
118 * Thin wrapper over the generic content-changes recorder
119 * (`includes/content-changes.php`) — the generic module
120 * owns the changelog AND the per-domain `os.<type>.changed`
121 * footer broadcasts, so a trash and a save flow through one emitter.
122 * The wrapper is kept because the recycle-bin hook wiring below and
123 * third-party code grep for it.
124 *
125 * Reads when called without args; records when called with a
126 * post_type.
127 *
128 * @param string $post_type Optional. Mutate this domain.
129 * @param int $post_id Optional. Id to record.
130 * @param string $action Optional. Verb (trashed/untrashed/deleted).
131 * @return array Full changelog when called with no args.
132 */
133 function openstation_recycle_bin_record_change( $post_type = '', $post_id = 0, $action = '' ) {
134 if ( '' !== $post_type ) {
135 openstation_content_changes_record( (string) $post_type, (int) $post_id, (string) $action );
136 }
137 return openstation_content_changes_log();
138 }
139
140 /**
141 * Whether the current chromeless request should emit the footer
142 * postMessage. Filterable so plugins can suppress the fast path
143 * (e.g. heavy load testing where 1 extra postMessage matters).
144 *
145 * NOTE: We emit on EVERY chromeless render — not only when this
146 * specific request mutated state. The reason is the dominant
147 * "delete" flow is form-POST → 302 → fresh GET: the request that
148 * actually trashed doesn't render a footer, only the redirect
149 * target does. By always emitting the current `..._change_ts`
150 * the parent shell gets a fresh ground-truth on the next page
151 * paint inside the iframe (typically <500ms after the click) and
152 * can refresh if its `seenTs` is older. The cost is one cached
153 * `get_option` + ~12 lines of inline JS per chromeless render.
154 *
155 * @return bool
156 */
157 function openstation_recycle_bin_should_emit_footer_signal() {
158 if ( ! function_exists( 'openstation_is_chromeless_request' ) ) {
159 return false;
160 }
161 if ( ! openstation_is_chromeless_request() ) {
162 return false;
163 }
164
165 /**
166 * Filter whether to emit the chromeless footer postMessage on
167 * the current request.
168 *
169 * @param bool $emit Default true on any chromeless render. The
170 * `openstation_recycle_bin_request_dirty()` helper
171 * reports whether THIS request itself mutated
172 * state — useful inside the filter for plugins
173 * that only want to ride the "this request
174 * trashed something" signal.
175 */
176 return (bool) apply_filters( 'openstation_recycle_bin_emit_footer_signal', true );
177 }
178
179 /**
180 * Emits the chromeless-iframe → parent footer signal.
181 *
182 * Runs at `admin_footer` priority 100 — well after most plugin
183 * footers so we don't race against unrelated emits. The inline
184 * script is ~14 lines uncompressed, doesn't import jQuery, and is
185 * a no-op when `window.parent === window` (defensive — the same
186 * gate the existing chromeless bridge uses).
187 */
188 function openstation_recycle_bin_emit_footer_signal() {
189 if ( ! openstation_recycle_bin_should_emit_footer_signal() ) {
190 return;
191 }
192
193 $ts = (int) get_option( OPENSTATION_RECYCLE_BIN_CHANGE_OPTION, 0 );
194
195 if ( $ts <= 0 ) {
196 // Nothing has ever been trashed via this site — no point
197 // teaching the parent shell about a 0 high-water mark.
198 return;
199 }
200
201 // Only the bin-specific ts signal is emitted here. The per-domain
202 // `os.<post_type>.changed` broadcasts moved to the
203 // generic content-changes emitter (`includes/content-changes.php`,
204 // same `admin_footer` slot) — the bin's changelog delegates into
205 // it, so a trash and a save flow through one emitter and each
206 // type/action pair is broadcast exactly once per render.
207 ?>
208 <script id="os-recycle-bin-realtime-signal">
209 ( function () {
210 if ( window.parent === window ) {
211 return;
212 }
213 try {
214 window.parent.postMessage(
215 {
216 type: 'os-recycle-bin-changed',
217 ts: <?php echo (int) $ts; ?>,
218 source: 'chromeless'
219 },
220 window.location.origin
221 );
222 } catch ( _err ) { /* swallow */ }
223 } )();
224 </script>
225 <?php
226 }
227
228 /**
229 * Heartbeat handler — answers "did anything change since you last
230 * heard from me?".
231 *
232 * The Heartbeat API runs server-side every 15s (active window),
233 * 60s (background tab), or 120s (idle). The bin's tab opts in by
234 * sending `openstation_recycle_bin_seen_ts` in its outgoing data; if the
235 * key is absent we early-return so users without the bin open pay
236 * zero per tick.
237 *
238 * @param array $response Heartbeat response (passed by ref via filter).
239 * @param array $data Client-sent payload.
240 * @return array
241 */
242 function openstation_recycle_bin_heartbeat_received( $response, $data ) {
243 if ( ! is_array( $response ) ) {
244 $response = array();
245 }
246 if ( ! isset( $data['openstation_recycle_bin_seen_ts'] ) ) {
247 return $response;
248 }
249 if ( function_exists( 'openstation_recycle_bin_user_can_use' ) && ! openstation_recycle_bin_user_can_use() ) {
250 return $response;
251 }
252
253 $seen = (int) $data['openstation_recycle_bin_seen_ts'];
254 $latest = (int) get_option( OPENSTATION_RECYCLE_BIN_CHANGE_OPTION, 0 );
255 $changed = $latest > $seen;
256
257 $response['openstation_recycle_bin'] = array(
258 'changed' => $changed,
259 'ts' => $latest,
260 );
261
262 // The authoritative count only travels when something actually
263 // changed since the client's high-water mark. The count cannot
264 // drift without the change-ts bumping (every capture / restore /
265 // purge bumps it), so an unchanged tick would recompute the same
266 // number — `openstation_recycle_bin_count()` runs up to two
267 // COUNT(*) WP_Querys plus a comment count, a real per-tick cost
268 // multiplied across every user with the shell open. The client
269 // treats `count` as optional and keeps its current badge value
270 // when the key is absent.
271 if ( $changed ) {
272 $response['openstation_recycle_bin']['count'] = openstation_recycle_bin_count();
273 }
274
275 return $response;
276 }
277
278 /**
279 * Wire the deletion hooks. We listen for both the WordPress core
280 * verbs (`wp_trash_post`, `untrash_post`, `before_delete_post`) and
281 * our own `openstation_recycle_bin_*` lifecycle actions — the former
282 * catches deletes that bypass our REST endpoints (Quick Edit, REST
283 * `DELETE`, WP-CLI, list-table bulk actions); the latter catches
284 * the bin's own restore/purge so other tabs see the change.
285 *
286 * Hooked together inside one bootstrap to make the wiring auditable
287 * — `grep openstation_recycle_bin_signal_change` finds every emitter.
288 */
289 function openstation_recycle_bin_register_realtime_hooks() {
290 add_action(
291 'wp_trash_post',
292 function ( $post_id ) {
293 openstation_recycle_bin_signal_change_for_post( $post_id, 'trashed' );
294 }
295 );
296 add_action(
297 'untrash_post',
298 function ( $post_id ) {
299 openstation_recycle_bin_signal_change_for_post( $post_id, 'untrashed' );
300 }
301 );
302 add_action(
303 'before_delete_post',
304 function ( $post_id ) {
305 openstation_recycle_bin_signal_change_for_post( $post_id, 'deleted' );
306 }
307 );
308
309 // Comments use a different verb space — `trashed_comment` /
310 // `untrashed_comment` / `deleted_comment` fire from
311 // `wp_set_comment_status`. Map each into our changelog so the
312 // chromeless footer can broadcast `os.comment.changed`
313 // to the Comments-list iframe; the bin captures and lists trashed
314 // comments too, and third-party plugins can subscribe to the same
315 // topic by hooking the changelog.
316 add_action(
317 'trashed_comment',
318 function ( $comment_id ) {
319 openstation_recycle_bin_record_change( 'comment', (int) $comment_id, 'trashed' );
320 openstation_recycle_bin_signal_change();
321 }
322 );
323 add_action(
324 'untrashed_comment',
325 function ( $comment_id ) {
326 openstation_recycle_bin_record_change( 'comment', (int) $comment_id, 'untrashed' );
327 openstation_recycle_bin_signal_change();
328 }
329 );
330 add_action(
331 'deleted_comment',
332 function ( $comment_id ) {
333 openstation_recycle_bin_record_change( 'comment', (int) $comment_id, 'deleted' );
334 openstation_recycle_bin_signal_change();
335 }
336 );
337
338 add_action( 'openstation_recycle_bin_item_captured', 'openstation_recycle_bin_signal_change' );
339 add_action( 'openstation_recycle_bin_after_restore', 'openstation_recycle_bin_signal_change' );
340 add_action( 'openstation_recycle_bin_after_purge', 'openstation_recycle_bin_signal_change' );
341 add_action( 'openstation_recycle_bin_emptied', 'openstation_recycle_bin_signal_change' );
342
343 add_action( 'admin_footer', 'openstation_recycle_bin_emit_footer_signal', 100 );
344
345 add_filter( 'heartbeat_received', 'openstation_recycle_bin_heartbeat_received', 10, 2 );
346 }
347 add_action( 'init', 'openstation_recycle_bin_register_realtime_hooks', 5 );
348