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

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