PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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.9.8, at includes/recycle-bin/realtime.php

323 lines 12.7 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. The per-domain
17 * `desktop-mode.<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 * `desktop_mode_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 WPDesktopMode
37 */
38
39 defined( 'ABSPATH' ) || exit;
40
41 const DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION = '_desktop_mode_recycle_bin_change_ts';
42
43 /**
44 * Per-request "did this request trigger a recycle-bin change" flag.
45 *
46 * Backed by a function-static so it survives across hook callbacks
47 * within the same PHP request. Reading without args returns the
48 * current value; passing `true` sets it.
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 function desktop_mode_recycle_bin_signal_change() {
73 $ts = (int) round( microtime( true ) * 1000 );
74 update_option( DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION, $ts, false );
75 desktop_mode_recycle_bin_request_dirty( true );
76
77 /**
78 * Fires after the recycle bin's "something changed" signal is
79 * bumped. Subscribers can use this to push their own real-time
80 * signal (websocket, SSE, etc.) without re-hooking every delete
81 * action individually.
82 *
83 * @param int $ts Milliseconds-since-epoch timestamp of the change.
84 */
85 do_action( 'desktop_mode_recycle_bin_signal', $ts );
86 }
87
88 /**
89 * Wrapper for `wp_trash_post`-style actions that pass `$post_id`.
90 *
91 * Captures the post id + post type into the per-request changelog
92 * so the chromeless footer can emit one `desktop-mode-broadcast`
93 * postMessage per affected domain (e.g. one for `post`, one for
94 * `attachment`, …). Subscribers — the recycle bin window, plus
95 * any plugin that registered a domain listener — react.
96 *
97 * @param int $post_id Post id being mutated.
98 * @param string $action One of 'trashed', 'untrashed', 'deleted'.
99 */
100 function desktop_mode_recycle_bin_signal_change_for_post( $post_id, $action = 'trashed' ) {
101 $post = get_post( $post_id );
102 if ( $post instanceof WP_Post ) {
103 desktop_mode_recycle_bin_record_change( (string) $post->post_type, (int) $post_id, (string) $action );
104 }
105 desktop_mode_recycle_bin_signal_change();
106 }
107
108 /**
109 * Per-request changelog: `[ post_type ][ action ] = int[] ids`.
110 *
111 * Thin wrapper over the generic content-changes recorder
112 * (`includes/content-changes.php`) — the generic module
113 * owns the changelog AND the per-domain `desktop-mode.<type>.changed`
114 * footer broadcasts, so a trash and a save flow through one emitter.
115 * The wrapper is kept because the recycle-bin hook wiring below and
116 * third-party code grep for it.
117 *
118 * Reads when called without args; records when called with a
119 * post_type.
120 *
121 * @param string $post_type Optional. Mutate this domain.
122 * @param int $post_id Optional. Id to record.
123 * @param string $action Optional. Verb (trashed/untrashed/deleted).
124 * @return array Full changelog when called with no args.
125 */
126 function desktop_mode_recycle_bin_record_change( $post_type = '', $post_id = 0, $action = '' ) {
127 if ( '' !== $post_type ) {
128 desktop_mode_content_changes_record( (string) $post_type, (int) $post_id, (string) $action );
129 }
130 return desktop_mode_content_changes_log();
131 }
132
133 /**
134 * Whether the current chromeless request should emit the footer
135 * postMessage. Filterable so plugins can suppress the fast path
136 * (e.g. heavy load testing where 1 extra postMessage matters).
137 *
138 * NOTE: We emit on EVERY chromeless render — not only when this
139 * specific request mutated state. The reason is the dominant
140 * "delete" flow is form-POST → 302 → fresh GET: the request that
141 * actually trashed doesn't render a footer, only the redirect
142 * target does. By always emitting the current `..._change_ts`
143 * the parent shell gets a fresh ground-truth on the next page
144 * paint inside the iframe (typically <500ms after the click) and
145 * can refresh if its `seenTs` is older. The cost is one cached
146 * `get_option` + ~12 lines of inline JS per chromeless render.
147 *
148 * @return bool
149 */
150 function desktop_mode_recycle_bin_should_emit_footer_signal() {
151 if ( ! function_exists( 'desktop_mode_is_chromeless_request' ) ) {
152 return false;
153 }
154 if ( ! desktop_mode_is_chromeless_request() ) {
155 return false;
156 }
157
158 /**
159 * Filter whether to emit the chromeless footer postMessage on
160 * the current request.
161 *
162 * @param bool $emit Default true on any chromeless render. The
163 * `desktop_mode_recycle_bin_request_dirty()` helper
164 * reports whether THIS request itself mutated
165 * state — useful inside the filter for plugins
166 * that only want to ride the "this request
167 * trashed something" signal.
168 */
169 return (bool) apply_filters( 'desktop_mode_recycle_bin_emit_footer_signal', true );
170 }
171
172 /**
173 * Emits the chromeless-iframe → parent footer signal.
174 *
175 * Runs at `admin_footer` priority 100 — well after most plugin
176 * footers so we don't race against unrelated emits. The inline
177 * script is ~14 lines uncompressed, doesn't import jQuery, and is
178 * a no-op when `window.parent === window` (defensive — the same
179 * gate the existing chromeless bridge uses).
180 */
181 function desktop_mode_recycle_bin_emit_footer_signal() {
182 if ( ! desktop_mode_recycle_bin_should_emit_footer_signal() ) {
183 return;
184 }
185
186 $ts = (int) get_option( DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION, 0 );
187
188 if ( $ts <= 0 ) {
189 // Nothing has ever been trashed via this site — no point
190 // teaching the parent shell about a 0 high-water mark.
191 return;
192 }
193
194 // Only the bin-specific ts signal is emitted here. The per-domain
195 // `desktop-mode.<post_type>.changed` broadcasts moved to the
196 // generic content-changes emitter (`includes/content-changes.php`,
197 // same `admin_footer` slot) — the bin's changelog delegates into
198 // it, so a trash and a save flow through one emitter and each
199 // type/action pair is broadcast exactly once per render.
200 ?>
201 <script id="desktop-mode-recycle-bin-realtime-signal">
202 ( function () {
203 if ( window.parent === window ) {
204 return;
205 }
206 try {
207 window.parent.postMessage(
208 {
209 type: 'desktop-mode-recycle-bin-changed',
210 ts: <?php echo (int) $ts; ?>,
211 source: 'chromeless'
212 },
213 window.location.origin
214 );
215 } catch ( _err ) { /* swallow */ }
216 } )();
217 </script>
218 <?php
219 }
220
221 /**
222 * Heartbeat handler — answers "did anything change since you last
223 * heard from me?".
224 *
225 * The Heartbeat API runs server-side every 15s (active window),
226 * 60s (background tab), or 120s (idle). The bin's tab opts in by
227 * sending `desktop_mode_recycle_bin_seen_ts` in its outgoing data; if the
228 * key is absent we early-return so users without the bin open pay
229 * zero per tick.
230 *
231 * @param array $response Heartbeat response (passed by ref via filter).
232 * @param array $data Client-sent payload.
233 * @return array
234 */
235 function desktop_mode_recycle_bin_heartbeat_received( $response, $data ) {
236 if ( ! is_array( $response ) ) {
237 $response = array();
238 }
239 if ( ! isset( $data['desktop_mode_recycle_bin_seen_ts'] ) ) {
240 return $response;
241 }
242 if ( function_exists( 'desktop_mode_recycle_bin_user_can_use' ) && ! desktop_mode_recycle_bin_user_can_use() ) {
243 return $response;
244 }
245
246 $seen = (int) $data['desktop_mode_recycle_bin_seen_ts'];
247 $latest = (int) get_option( DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION, 0 );
248 $changed = $latest > $seen;
249
250 $response['desktop_mode_recycle_bin'] = array(
251 'changed' => $changed,
252 'ts' => $latest,
253 );
254
255 // The authoritative count only travels when something actually
256 // changed since the client's high-water mark. The count cannot
257 // drift without the change-ts bumping (every capture / restore /
258 // purge bumps it), so an unchanged tick would recompute the same
259 // number — `desktop_mode_recycle_bin_count()` runs up to two
260 // COUNT(*) WP_Querys plus a comment count, a real per-tick cost
261 // multiplied across every user with the shell open. The client
262 // treats `count` as optional and keeps its current badge value
263 // when the key is absent.
264 if ( $changed ) {
265 $response['desktop_mode_recycle_bin']['count'] = desktop_mode_recycle_bin_count();
266 }
267
268 return $response;
269 }
270
271 /**
272 * Wire the deletion hooks. We listen for both the WordPress core
273 * verbs (`wp_trash_post`, `untrash_post`, `before_delete_post`) and
274 * our own `desktop_mode_recycle_bin_*` lifecycle actions — the former
275 * catches deletes that bypass our REST endpoints (Quick Edit, REST
276 * `DELETE`, WP-CLI, list-table bulk actions); the latter catches
277 * the bin's own restore/purge so other tabs see the change.
278 *
279 * Hooked together inside one bootstrap to make the wiring auditable
280 * — `grep desktop_mode_recycle_bin_signal_change` finds every emitter.
281 */
282 function desktop_mode_recycle_bin_register_realtime_hooks() {
283 add_action( 'wp_trash_post', function ( $post_id ) {
284 desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'trashed' );
285 } );
286 add_action( 'untrash_post', function ( $post_id ) {
287 desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'untrashed' );
288 } );
289 add_action( 'before_delete_post', function ( $post_id ) {
290 desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'deleted' );
291 } );
292
293 // Comments use a different verb space — `trashed_comment` /
294 // `untrashed_comment` / `deleted_comment` fire from
295 // `wp_set_comment_status`. Map each into our changelog so the
296 // chromeless footer can broadcast `desktop-mode.comment.changed`
297 // to the Comments-list iframe; the bin captures and lists trashed
298 // comments too, and third-party plugins can subscribe to the same
299 // topic by hooking the changelog.
300 add_action( 'trashed_comment', function ( $comment_id ) {
301 desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'trashed' );
302 desktop_mode_recycle_bin_signal_change();
303 } );
304 add_action( 'untrashed_comment', function ( $comment_id ) {
305 desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'untrashed' );
306 desktop_mode_recycle_bin_signal_change();
307 } );
308 add_action( 'deleted_comment', function ( $comment_id ) {
309 desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'deleted' );
310 desktop_mode_recycle_bin_signal_change();
311 } );
312
313 add_action( 'desktop_mode_recycle_bin_item_captured', 'desktop_mode_recycle_bin_signal_change' );
314 add_action( 'desktop_mode_recycle_bin_after_restore', 'desktop_mode_recycle_bin_signal_change' );
315 add_action( 'desktop_mode_recycle_bin_after_purge', 'desktop_mode_recycle_bin_signal_change' );
316 add_action( 'desktop_mode_recycle_bin_emptied', 'desktop_mode_recycle_bin_signal_change' );
317
318 add_action( 'admin_footer', 'desktop_mode_recycle_bin_emit_footer_signal', 100 );
319
320 add_filter( 'heartbeat_received', 'desktop_mode_recycle_bin_heartbeat_received', 10, 2 );
321 }
322 add_action( 'init', 'desktop_mode_recycle_bin_register_realtime_hooks', 5 );
323