PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / recycle-bin / realtime.php

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

369 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — 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 * `desktop_mode_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 * 'desktop-mode-recycle-bin-changed'`. The parent dispatches our
15 * `CustomEvent`, the open window refreshes. Cost: ~zero unless
16 * a delete actually happened in this request.
17 *
18 * 2. **Catch-all path — Heartbeat**
19 * Every delete also bumps a single autoload=false option
20 * `_desktop_mode_recycle_bin_change_ts` (a millisecond timestamp).
21 * The Heartbeat `heartbeat_received` filter checks the client's
22 * last-seen ts — if the option is newer, the response includes
23 * `desktop_mode_recycle_bin: { changed, ts }`. The bin only subscribes
24 * while its window is open, so users without the bin open pay
25 * zero. The cost per tick is one cached option read.
26 *
27 * Why two paths: chromeless iframes that produce a footer (form
28 * POST → redirect → re-render, the dominant pattern for "Move to
29 * Trash" buttons) get instant updates. Everything else (AJAX list
30 * actions, REST `DELETE`, other browser tabs, WP-CLI, cron) drips
31 * in within the heartbeat cadence (15s active, 60s away).
32 *
33 * @package WPDesktopMode
34 * @since 0.20.0
35 */
36
37 defined( 'ABSPATH' ) || exit;
38
39 const DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION = '_desktop_mode_recycle_bin_change_ts';
40
41 /**
42 * Per-request "did this request trigger a recycle-bin change" flag.
43 *
44 * Backed by a function-static so it survives across hook callbacks
45 * within the same PHP request. Reading without args returns the
46 * current value; passing `true` sets it.
47 *
48 * @since 0.20.0
49 *
50 * @param bool|null $set Set the flag.
51 * @return bool
52 */
53 function desktop_mode_recycle_bin_request_dirty( $set = null ) {
54 static $dirty = false;
55 if ( null !== $set ) {
56 $dirty = (bool) $set;
57 }
58 return $dirty;
59 }
60
61 /**
62 * Bump the global change timestamp + flip the per-request flag.
63 *
64 * Called from every recycle-bin-relevant hook. The timestamp is a
65 * milliseconds-since-epoch integer so client comparisons are
66 * straightforward and we don't need locale/timezone parsing.
67 *
68 * Stored as autoload=false to keep the option out of the always-
69 * loaded options query — recycle-bin polling is a "you opened the
70 * window, you opted in" cost, not a per-pageload cost.
71 *
72 * @since 0.20.0
73 */
74 function desktop_mode_recycle_bin_signal_change() {
75 $ts = (int) round( microtime( true ) * 1000 );
76 update_option( DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION, $ts, false );
77 desktop_mode_recycle_bin_request_dirty( true );
78
79 /**
80 * Fires after the recycle bin's "something changed" signal is
81 * bumped. Subscribers can use this to push their own real-time
82 * signal (websocket, SSE, etc.) without re-hooking every delete
83 * action individually.
84 *
85 * @since 0.20.0
86 *
87 * @param int $ts Milliseconds-since-epoch timestamp of the change.
88 */
89 do_action( 'desktop_mode_recycle_bin_signal', $ts );
90 }
91
92 /**
93 * Wrapper for `wp_trash_post`-style actions that pass `$post_id`.
94 *
95 * Captures the post id + post type into the per-request changelog
96 * so the chromeless footer can emit one `desktop-mode-broadcast`
97 * postMessage per affected domain (e.g. one for `post`, one for
98 * `attachment`, …). Subscribers — the recycle bin window, plus
99 * any plugin that registered a domain listener — react.
100 *
101 * @since 0.20.0
102 *
103 * @param int $post_id Post id being mutated.
104 * @param string $action One of 'trashed', 'untrashed', 'deleted'.
105 */
106 function desktop_mode_recycle_bin_signal_change_for_post( $post_id, $action = 'trashed' ) {
107 $post = get_post( $post_id );
108 if ( $post instanceof WP_Post ) {
109 desktop_mode_recycle_bin_record_change( (string) $post->post_type, (int) $post_id, (string) $action );
110 }
111 desktop_mode_recycle_bin_signal_change();
112 }
113
114 /**
115 * Per-request changelog: `[ post_type ][ action ] = int[] ids`.
116 *
117 * Reads when called without args; mutates when called with a
118 * post_type. Static-store pattern, same shape as the dirty
119 * helper above so test introspection is symmetric.
120 *
121 * @since 0.21.0
122 *
123 * @param string $post_type Optional. Mutate this domain.
124 * @param int $post_id Optional. Id to record.
125 * @param string $action Optional. Verb (trashed/untrashed/deleted).
126 * @return array Full changelog when called with no args.
127 */
128 function desktop_mode_recycle_bin_record_change( $post_type = '', $post_id = 0, $action = '' ) {
129 static $log = array();
130
131 if ( '' === $post_type ) {
132 return $log;
133 }
134 if ( ! isset( $log[ $post_type ] ) ) {
135 $log[ $post_type ] = array();
136 }
137 if ( ! isset( $log[ $post_type ][ $action ] ) ) {
138 $log[ $post_type ][ $action ] = array();
139 }
140 $log[ $post_type ][ $action ][] = (int) $post_id;
141 return $log;
142 }
143
144 /**
145 * Whether the current chromeless request should emit the footer
146 * postMessage. Filterable so plugins can suppress the fast path
147 * (e.g. heavy load testing where 1 extra postMessage matters).
148 *
149 * NOTE: We emit on EVERY chromeless render — not only when this
150 * specific request mutated state. The reason is the dominant
151 * "delete" flow is form-POST → 302 → fresh GET: the request that
152 * actually trashed doesn't render a footer, only the redirect
153 * target does. By always emitting the current `..._change_ts`
154 * the parent shell gets a fresh ground-truth on the next page
155 * paint inside the iframe (typically <500ms after the click) and
156 * can refresh if its `seenTs` is older. The cost is one cached
157 * `get_option` + ~12 lines of inline JS per chromeless render.
158 *
159 * @since 0.20.0
160 *
161 * @return bool
162 */
163 function desktop_mode_recycle_bin_should_emit_footer_signal() {
164 if ( ! function_exists( 'desktop_mode_is_chromeless_request' ) ) {
165 return false;
166 }
167 if ( ! desktop_mode_is_chromeless_request() ) {
168 return false;
169 }
170
171 /**
172 * Filter whether to emit the chromeless footer postMessage on
173 * the current request.
174 *
175 * @since 0.20.0
176 *
177 * @param bool $emit Default true on any chromeless render. The
178 * `desktop_mode_recycle_bin_request_dirty()` helper
179 * reports whether THIS request itself mutated
180 * state — useful inside the filter for plugins
181 * that only want to ride the "this request
182 * trashed something" signal.
183 */
184 return (bool) apply_filters( 'desktop_mode_recycle_bin_emit_footer_signal', true );
185 }
186
187 /**
188 * Emits the chromeless-iframe → parent footer signal.
189 *
190 * Runs at `admin_footer` priority 100 — well after most plugin
191 * footers so we don't race against unrelated emits. The inline
192 * script is ~14 lines uncompressed, doesn't import jQuery, and is
193 * a no-op when `window.parent === window` (defensive — the same
194 * gate the existing chromeless bridge uses).
195 *
196 * @since 0.20.0
197 */
198 function desktop_mode_recycle_bin_emit_footer_signal() {
199 if ( ! desktop_mode_recycle_bin_should_emit_footer_signal() ) {
200 return;
201 }
202
203 $ts = (int) get_option( DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION, 0 );
204 $changelog = desktop_mode_recycle_bin_record_change();
205
206 if ( $ts <= 0 && empty( $changelog ) ) {
207 // Nothing has ever been trashed via this site — no point
208 // teaching the parent shell about a 0 high-water mark.
209 return;
210 }
211
212 // Per-domain broadcast envelope: one entry per affected
213 // post_type, with verb-keyed id lists. The parent shell's
214 // `installBroadcastReceiver` translates each into a
215 // `desktop-mode.<post_type>.changed` broadcast — the bin (and
216 // any plugin that subscribes to that exact topic) reacts.
217 $broadcasts = array();
218 foreach ( $changelog as $post_type => $by_action ) {
219 foreach ( $by_action as $action => $ids ) {
220 $broadcasts[] = array(
221 'topic' => 'desktop-mode.' . $post_type . '.changed',
222 'payload' => array(
223 'source' => 'admin',
224 'action' => (string) $action,
225 'ids' => array_values( array_unique( array_map( 'intval', $ids ) ) ),
226 ),
227 );
228 }
229 }
230 $broadcasts_json = wp_json_encode( $broadcasts );
231 ?>
232 <script id="desktop-mode-recycle-bin-realtime-signal">
233 ( function () {
234 if ( window.parent === window ) {
235 return;
236 }
237 var origin = window.location.origin;
238 try {
239 window.parent.postMessage(
240 {
241 type: 'desktop-mode-recycle-bin-changed',
242 ts: <?php echo (int) $ts; ?>,
243 source: 'chromeless'
244 },
245 origin
246 );
247 } catch ( _err ) { /* swallow */ }
248
249 /*
250 * Per-domain broadcast envelopes — one postMessage per
251 * affected post type. The parent shell's broadcast
252 * receiver fans these out as `desktop-mode.<type>.changed`
253 * subscriptions. Only emitted when the request actually
254 * mutated something: a no-op chromeless render skips this
255 * branch entirely.
256 */
257 var broadcasts = <?php echo $broadcasts_json ? $broadcasts_json : '[]'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>;
258 for ( var i = 0; i < broadcasts.length; i++ ) {
259 try {
260 window.parent.postMessage( {
261 type: 'desktop-mode-broadcast',
262 topic: broadcasts[ i ].topic,
263 payload: broadcasts[ i ].payload
264 }, origin );
265 } catch ( _err ) { /* swallow */ }
266 }
267 } )();
268 </script>
269 <?php
270 }
271
272 /**
273 * Heartbeat handler — answers "did anything change since you last
274 * heard from me?".
275 *
276 * The Heartbeat API runs server-side every 15s (active window),
277 * 60s (background tab), or 120s (idle). The bin's tab opts in by
278 * sending `desktop_mode_recycle_bin_seen_ts` in its outgoing data; if the
279 * key is absent we early-return so users without the bin open pay
280 * zero per tick.
281 *
282 * @since 0.20.0
283 *
284 * @param array $response Heartbeat response (passed by ref via filter).
285 * @param array $data Client-sent payload.
286 * @return array
287 */
288 function desktop_mode_recycle_bin_heartbeat_received( $response, $data ) {
289 if ( ! is_array( $response ) ) {
290 $response = array();
291 }
292 if ( ! isset( $data['desktop_mode_recycle_bin_seen_ts'] ) ) {
293 return $response;
294 }
295 if ( function_exists( 'desktop_mode_recycle_bin_user_can_use' ) && ! desktop_mode_recycle_bin_user_can_use() ) {
296 return $response;
297 }
298
299 $seen = (int) $data['desktop_mode_recycle_bin_seen_ts'];
300 $latest = (int) get_option( DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION, 0 );
301
302 // Authoritative count travels on every tick — it's the cheapest
303 // way to keep the dock/icon badge truthful when the bin window
304 // is closed. `desktop_mode_recycle_bin_count()` is a fast COUNT(*) that
305 // hits the same option-cached query each post-status.
306 $response['desktop_mode_recycle_bin'] = array(
307 'changed' => $latest > $seen,
308 'ts' => $latest,
309 'count' => desktop_mode_recycle_bin_count(),
310 );
311
312 return $response;
313 }
314
315 /**
316 * Wire the deletion hooks. We listen for both the WordPress core
317 * verbs (`wp_trash_post`, `untrash_post`, `before_delete_post`) and
318 * our own `desktop_mode_recycle_bin_*` lifecycle actions — the former
319 * catches deletes that bypass our REST endpoints (Quick Edit, REST
320 * `DELETE`, WP-CLI, list-table bulk actions); the latter catches
321 * the bin's own restore/purge so other tabs see the change.
322 *
323 * Hooked together inside one bootstrap to make the wiring auditable
324 * — `grep desktop_mode_recycle_bin_signal_change` finds every emitter.
325 *
326 * @since 0.20.0
327 */
328 function desktop_mode_recycle_bin_register_realtime_hooks() {
329 add_action( 'wp_trash_post', function ( $post_id ) {
330 desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'trashed' );
331 } );
332 add_action( 'untrash_post', function ( $post_id ) {
333 desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'untrashed' );
334 } );
335 add_action( 'before_delete_post', function ( $post_id ) {
336 desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'deleted' );
337 } );
338
339 // Comments use a different verb space — `trashed_comment` /
340 // `untrashed_comment` / `deleted_comment` fire from
341 // `wp_set_comment_status`. Map each into our changelog so the
342 // chromeless footer can broadcast `desktop-mode.comment.changed`
343 // to the Comments-list iframe; the bin doesn't capture comments
344 // today, but having the topic available means a third-party
345 // "comment trash" plugin can opt in by hooking the changelog.
346 add_action( 'trashed_comment', function ( $comment_id ) {
347 desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'trashed' );
348 desktop_mode_recycle_bin_signal_change();
349 } );
350 add_action( 'untrashed_comment', function ( $comment_id ) {
351 desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'untrashed' );
352 desktop_mode_recycle_bin_signal_change();
353 } );
354 add_action( 'deleted_comment', function ( $comment_id ) {
355 desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'deleted' );
356 desktop_mode_recycle_bin_signal_change();
357 } );
358
359 add_action( 'desktop_mode_recycle_bin_item_captured', 'desktop_mode_recycle_bin_signal_change' );
360 add_action( 'desktop_mode_recycle_bin_after_restore', 'desktop_mode_recycle_bin_signal_change' );
361 add_action( 'desktop_mode_recycle_bin_after_purge', 'desktop_mode_recycle_bin_signal_change' );
362 add_action( 'desktop_mode_recycle_bin_emptied', 'desktop_mode_recycle_bin_signal_change' );
363
364 add_action( 'admin_footer', 'desktop_mode_recycle_bin_emit_footer_signal', 100 );
365
366 add_filter( 'heartbeat_received', 'desktop_mode_recycle_bin_heartbeat_received', 10, 2 );
367 }
368 add_action( 'init', 'desktop_mode_recycle_bin_register_realtime_hooks', 5 );
369