post_type, (int) $post_id, (string) $action ); } desktop_mode_recycle_bin_signal_change(); } /** * Per-request changelog: `[ post_type ][ action ] = int[] ids`. * * Reads when called without args; mutates when called with a * post_type. Static-store pattern, same shape as the dirty * helper above so test introspection is symmetric. * * @since 0.21.0 * * @param string $post_type Optional. Mutate this domain. * @param int $post_id Optional. Id to record. * @param string $action Optional. Verb (trashed/untrashed/deleted). * @return array Full changelog when called with no args. */ function desktop_mode_recycle_bin_record_change( $post_type = '', $post_id = 0, $action = '' ) { static $log = array(); if ( '' === $post_type ) { return $log; } if ( ! isset( $log[ $post_type ] ) ) { $log[ $post_type ] = array(); } if ( ! isset( $log[ $post_type ][ $action ] ) ) { $log[ $post_type ][ $action ] = array(); } $log[ $post_type ][ $action ][] = (int) $post_id; return $log; } /** * Whether the current chromeless request should emit the footer * postMessage. Filterable so plugins can suppress the fast path * (e.g. heavy load testing where 1 extra postMessage matters). * * NOTE: We emit on EVERY chromeless render — not only when this * specific request mutated state. The reason is the dominant * "delete" flow is form-POST → 302 → fresh GET: the request that * actually trashed doesn't render a footer, only the redirect * target does. By always emitting the current `..._change_ts` * the parent shell gets a fresh ground-truth on the next page * paint inside the iframe (typically <500ms after the click) and * can refresh if its `seenTs` is older. The cost is one cached * `get_option` + ~12 lines of inline JS per chromeless render. * * @since 0.20.0 * * @return bool */ function desktop_mode_recycle_bin_should_emit_footer_signal() { if ( ! function_exists( 'desktop_mode_is_chromeless_request' ) ) { return false; } if ( ! desktop_mode_is_chromeless_request() ) { return false; } /** * Filter whether to emit the chromeless footer postMessage on * the current request. * * @since 0.20.0 * * @param bool $emit Default true on any chromeless render. The * `desktop_mode_recycle_bin_request_dirty()` helper * reports whether THIS request itself mutated * state — useful inside the filter for plugins * that only want to ride the "this request * trashed something" signal. */ return (bool) apply_filters( 'desktop_mode_recycle_bin_emit_footer_signal', true ); } /** * Emits the chromeless-iframe → parent footer signal. * * Runs at `admin_footer` priority 100 — well after most plugin * footers so we don't race against unrelated emits. The inline * script is ~14 lines uncompressed, doesn't import jQuery, and is * a no-op when `window.parent === window` (defensive — the same * gate the existing chromeless bridge uses). * * @since 0.20.0 */ function desktop_mode_recycle_bin_emit_footer_signal() { if ( ! desktop_mode_recycle_bin_should_emit_footer_signal() ) { return; } $ts = (int) get_option( DESKTOP_MODE_RECYCLE_BIN_CHANGE_OPTION, 0 ); $changelog = desktop_mode_recycle_bin_record_change(); if ( $ts <= 0 && empty( $changelog ) ) { // Nothing has ever been trashed via this site — no point // teaching the parent shell about a 0 high-water mark. return; } // Per-domain broadcast envelope: one entry per affected // post_type, with verb-keyed id lists. The parent shell's // `installBroadcastReceiver` translates each into a // `desktop-mode..changed` broadcast — the bin (and // any plugin that subscribes to that exact topic) reacts. $broadcasts = array(); foreach ( $changelog as $post_type => $by_action ) { foreach ( $by_action as $action => $ids ) { $broadcasts[] = array( 'topic' => 'desktop-mode.' . $post_type . '.changed', 'payload' => array( 'source' => 'admin', 'action' => (string) $action, 'ids' => array_values( array_unique( array_map( 'intval', $ids ) ) ), ), ); } } $broadcasts_json = wp_json_encode( $broadcasts ); ?> $latest > $seen, 'ts' => $latest, 'count' => desktop_mode_recycle_bin_count(), ); return $response; } /** * Wire the deletion hooks. We listen for both the WordPress core * verbs (`wp_trash_post`, `untrash_post`, `before_delete_post`) and * our own `desktop_mode_recycle_bin_*` lifecycle actions — the former * catches deletes that bypass our REST endpoints (Quick Edit, REST * `DELETE`, WP-CLI, list-table bulk actions); the latter catches * the bin's own restore/purge so other tabs see the change. * * Hooked together inside one bootstrap to make the wiring auditable * — `grep desktop_mode_recycle_bin_signal_change` finds every emitter. * * @since 0.20.0 */ function desktop_mode_recycle_bin_register_realtime_hooks() { add_action( 'wp_trash_post', function ( $post_id ) { desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'trashed' ); } ); add_action( 'untrash_post', function ( $post_id ) { desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'untrashed' ); } ); add_action( 'before_delete_post', function ( $post_id ) { desktop_mode_recycle_bin_signal_change_for_post( $post_id, 'deleted' ); } ); // Comments use a different verb space — `trashed_comment` / // `untrashed_comment` / `deleted_comment` fire from // `wp_set_comment_status`. Map each into our changelog so the // chromeless footer can broadcast `desktop-mode.comment.changed` // to the Comments-list iframe; the bin doesn't capture comments // today, but having the topic available means a third-party // "comment trash" plugin can opt in by hooking the changelog. add_action( 'trashed_comment', function ( $comment_id ) { desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'trashed' ); desktop_mode_recycle_bin_signal_change(); } ); add_action( 'untrashed_comment', function ( $comment_id ) { desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'untrashed' ); desktop_mode_recycle_bin_signal_change(); } ); add_action( 'deleted_comment', function ( $comment_id ) { desktop_mode_recycle_bin_record_change( 'comment', (int) $comment_id, 'deleted' ); desktop_mode_recycle_bin_signal_change(); } ); add_action( 'desktop_mode_recycle_bin_item_captured', 'desktop_mode_recycle_bin_signal_change' ); add_action( 'desktop_mode_recycle_bin_after_restore', 'desktop_mode_recycle_bin_signal_change' ); add_action( 'desktop_mode_recycle_bin_after_purge', 'desktop_mode_recycle_bin_signal_change' ); add_action( 'desktop_mode_recycle_bin_emptied', 'desktop_mode_recycle_bin_signal_change' ); add_action( 'admin_footer', 'desktop_mode_recycle_bin_emit_footer_signal', 100 ); add_filter( 'heartbeat_received', 'desktop_mode_recycle_bin_heartbeat_received', 10, 2 ); } add_action( 'init', 'desktop_mode_recycle_bin_register_realtime_hooks', 5 );