PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 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 All 36 releases
desktop-mode / includes / seen-intros.php

seen-intros.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.12, at includes/seen-intros.php

320 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — "Seen intros" registry.
4 *
5 * Tracks which one-time announcements the current user has already
6 * dismissed, so each is shown once and never bothers them again.
7 *
8 * Three surfaces use it today: the activation welcome dialog
9 * (`includes/welcome-dialog.php`, slug `activation-welcome`), shown
10 * in the classic admin while OpenStation is disabled, the rebrand
11 * notice (`src/rebrand-notice.ts`, slug `openstation-rebrand`), and
12 * the usage feedback prompt (`includes/feedback/usage.php`, slug
13 * `usage-feedback`, marked server-side on a successful send). The
14 * key is intentionally generic, so anything else that needs
15 * show-once semantics registers its own slug and reuses this storage.
16 * OpenStation Preferences → Features exposes a "Reset what's-new
17 * dialogs" button that clears the whole list.
18 *
19 * Storage shape:
20 * user meta `desktop_mode_seen_intros` → array<string> of slugs.
21 * `[ 'activation-welcome' ]`, `[ 'openstation-rebrand' ]`, etc.
22 * Slug values pass through `sanitize_key()` and the list is capped
23 * at 64 entries so a runaway client cannot bloat user-meta
24 * indefinitely.
25 *
26 * @package OpenStation
27 */
28
29 defined( 'ABSPATH' ) || exit;
30
31 /**
32 * User meta key — see file header for shape.
33 *
34 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
35 * persisted or externally-visible identifier, so renaming it would
36 * orphan data already written by live installs (or break a live
37 * URL). The mismatch between this constant's name and its value is
38 * deliberate — it is NOT a half-finished rename.
39 */
40 const OPENSTATION_SEEN_INTROS_META_KEY = 'desktop_mode_seen_intros';
41
42 /** Hard cap so a malicious client cannot grow the list unbounded. */
43 const OPENSTATION_SEEN_INTROS_MAX = 64;
44
45 /**
46 * Returns the list of intro slugs the user has dismissed.
47 *
48 * @param int $user_id User ID.
49 * @return string[] Sanitized list (may be empty).
50 */
51 function openstation_get_seen_intros( $user_id ) {
52 $user_id = (int) $user_id;
53 if ( $user_id <= 0 ) {
54 return array();
55 }
56
57 $raw = get_user_meta( $user_id, OPENSTATION_SEEN_INTROS_META_KEY, true );
58 if ( ! is_array( $raw ) ) {
59 return array();
60 }
61
62 return openstation_sanitize_seen_intros( $raw );
63 }
64
65 /**
66 * Whether the user has already dismissed the given intro.
67 *
68 * @param int $user_id User ID.
69 * @param string $slug Intro slug (e.g. `'posts'`).
70 * @return bool
71 */
72 function openstation_has_seen_intro( $user_id, $slug ) {
73 $slug = sanitize_key( (string) $slug );
74 if ( '' === $slug ) {
75 return false;
76 }
77 return in_array( $slug, openstation_get_seen_intros( $user_id ), true );
78 }
79
80 /**
81 * Adds a slug to the user's seen-intros list.
82 *
83 * Idempotent — re-marking an already-seen intro is a no-op that
84 * still returns true. A slug that supersedes others
85 * ({@see openstation_seen_intros_superseded_by()}) removes them first.
86 *
87 * @param int $user_id User ID.
88 * @param string $slug Intro slug.
89 * @return bool True on successful write (or no-op), false otherwise.
90 */
91 function openstation_mark_intro_seen( $user_id, $slug ) {
92 $user_id = (int) $user_id;
93 $slug = sanitize_key( (string) $slug );
94 if ( $user_id <= 0 || '' === $slug ) {
95 return false;
96 }
97
98 $current = openstation_get_seen_intros( $user_id );
99 $kept = array_values( array_diff( $current, openstation_seen_intros_superseded_by( $slug ) ) );
100 if ( in_array( $slug, $kept, true ) && count( $kept ) === count( $current ) ) {
101 return true;
102 }
103
104 if ( ! in_array( $slug, $kept, true ) ) {
105 $kept[] = $slug;
106 }
107 $kept = array_slice( $kept, 0, OPENSTATION_SEEN_INTROS_MAX );
108
109 return false !== update_user_meta(
110 $user_id,
111 OPENSTATION_SEEN_INTROS_META_KEY,
112 $kept
113 );
114 }
115
116 /**
117 * Slugs a newly recorded one makes obsolete: facts where only the
118 * latest counts, which an append-only list cannot otherwise express.
119 *
120 * The shell tour's two outcomes are the one pair. A run ends skipped
121 * or finished, and the relaunch icon asks about the LATEST run: kept
122 * side by side, one finished run long ago hid the icon after every
123 * skip that came later. The strings mirror the constants in
124 * `includes/first-run/shell-tour.php`, which loads after this file.
125 *
126 * @param string $slug The slug being recorded.
127 * @return string[] Slugs it replaces.
128 */
129 function openstation_seen_intros_superseded_by( $slug ) {
130 $pairs = array(
131 'shell-tour-skipped' => array( 'shell-tour-done' ),
132 'shell-tour-done' => array( 'shell-tour-skipped' ),
133 );
134 return isset( $pairs[ $slug ] ) ? $pairs[ $slug ] : array();
135 }
136
137 /**
138 * Wipes every seen-intro entry for the user. Used by the OS
139 * Settings → Features "Reset what's-new dialogs" button.
140 *
141 * @param int $user_id User ID.
142 * @return bool True on success.
143 */
144 function openstation_clear_seen_intros( $user_id ) {
145 $user_id = (int) $user_id;
146 if ( $user_id <= 0 ) {
147 return false;
148 }
149 return (bool) delete_user_meta( $user_id, OPENSTATION_SEEN_INTROS_META_KEY );
150 }
151
152 /**
153 * Coerces a raw payload to a clean list of slugs.
154 *
155 * @param mixed $raw Raw value.
156 * @return string[]
157 */
158 function openstation_sanitize_seen_intros( $raw ) {
159 if ( ! is_array( $raw ) ) {
160 return array();
161 }
162 $out = array();
163 foreach ( $raw as $entry ) {
164 if ( ! is_string( $entry ) ) {
165 continue;
166 }
167 $slug = sanitize_key( $entry );
168 if ( '' === $slug ) {
169 continue;
170 }
171 $out[] = $slug;
172 }
173 return array_slice( array_values( array_unique( $out ) ), 0, OPENSTATION_SEEN_INTROS_MAX );
174 }
175
176 /**
177 * Registers REST routes for the seen-intros surface.
178 *
179 * Routes:
180 * POST /desktop-mode/v1/intros/seen body: { slug: string }
181 * DELETE /desktop-mode/v1/intros no body — clears the list
182 *
183 * Both return the post-mutation list so the client can refresh its
184 * local snapshot without a follow-up GET.
185 */
186 function openstation_register_seen_intros_routes() {
187 register_rest_route(
188 'desktop-mode/v1',
189 '/intros/seen',
190 array(
191 'methods' => WP_REST_Server::CREATABLE,
192 'callback' => 'openstation_rest_mark_intro_seen',
193 'permission_callback' => 'openstation_rest_seen_intros_permission',
194 'args' => array(
195 'slug' => array(
196 'required' => true,
197 'type' => 'string',
198 ),
199 ),
200 )
201 );
202
203 register_rest_route(
204 'desktop-mode/v1',
205 '/intros',
206 array(
207 'methods' => WP_REST_Server::DELETABLE,
208 'callback' => 'openstation_rest_clear_seen_intros',
209 'permission_callback' => 'openstation_rest_seen_intros_permission',
210 )
211 );
212 }
213 add_action( 'rest_api_init', 'openstation_register_seen_intros_routes' );
214
215 /**
216 * The intro slugs whose dismissal is accepted from an account that has
217 * NOT enabled OpenStation.
218 *
219 * Exactly the intros that render in the classic admin while the shell
220 * is off: the welcome dialog and the activation nudge. Everything else
221 * is shown inside the shell and keeps the strict gate. Adding a slug
222 * here is adding a classic-admin surface; the allowlist is the review
223 * point, so keep it a literal list.
224 *
225 * @return string[]
226 */
227 function openstation_seen_intros_classic_admin_slugs() {
228 $slugs = array();
229 if ( defined( 'OPENSTATION_WELCOME_INTRO_SLUG' ) ) {
230 $slugs[] = OPENSTATION_WELCOME_INTRO_SLUG;
231 }
232 if ( defined( 'OPENSTATION_ACTIVATION_NUDGE_INTRO_SLUG' ) ) {
233 $slugs[] = OPENSTATION_ACTIVATION_NUDGE_INTRO_SLUG;
234 }
235 return $slugs;
236 }
237
238 /**
239 * Permission gate for the seen-intros routes.
240 *
241 * In-shell announcements (the rebrand notice, and anything a plugin
242 * registers) are only ever shown to a user who has already entered
243 * OpenStation, so they keep the strict
244 * {@see openstation_rest_require_enabled()} gate — `read` alone is
245 * insufficient (every role, Subscriber included, carries `read`).
246 *
247 * The exceptions are the classic-admin intros
248 * ({@see openstation_seen_intros_classic_admin_slugs()}): the first-run
249 * welcome dialog and the activation nudge both render in the *classic*
250 * admin precisely when OpenStation is NOT enabled, which is the only
251 * state they ever appear in. Gating their dismissal behind
252 * `openstation_rest_require_enabled()` would make the dismissal POST
253 * return 403 every time, so the slug could never be recorded as seen and
254 * the dialog / notice re-rendered on every classic-admin page load. We
255 * therefore let those slugs through for any logged-in `read`-capable
256 * account (the exact audience they are shown to); writing one's own
257 * dismissal flag carries no privileged surface. The DELETE /intros route
258 * ("Reset what's-new dialogs") carries no slug and keeps the strict gate.
259 *
260 * @param WP_REST_Request $request The REST request.
261 * @return true|WP_Error
262 */
263 function openstation_rest_seen_intros_permission( WP_REST_Request $request ) {
264 $slug = sanitize_key( (string) $request->get_param( 'slug' ) );
265 if ( '' !== $slug && in_array( $slug, openstation_seen_intros_classic_admin_slugs(), true ) ) {
266 if ( ! is_user_logged_in() ) {
267 return new WP_Error(
268 'rest_forbidden',
269 __( 'Authentication required.', 'desktop-mode' ),
270 array( 'status' => 401 )
271 );
272 }
273 if ( ! current_user_can( 'read' ) ) {
274 return new WP_Error(
275 'rest_forbidden',
276 __( 'You are not allowed to do that.', 'desktop-mode' ),
277 array( 'status' => 403 )
278 );
279 }
280 return true;
281 }
282
283 return openstation_rest_require_enabled();
284 }
285
286 /**
287 * REST handler for `POST /desktop-mode/v1/intros/seen`.
288 *
289 * @param WP_REST_Request $request REST request.
290 * @return WP_REST_Response|WP_Error
291 */
292 function openstation_rest_mark_intro_seen( WP_REST_Request $request ) {
293 $user_id = get_current_user_id();
294 $slug = sanitize_key( (string) $request->get_param( 'slug' ) );
295 if ( '' === $slug ) {
296 return new WP_Error(
297 'openstation_invalid_intro_slug',
298 __( 'The `slug` parameter must be a non-empty string.', 'desktop-mode' ),
299 array( 'status' => 400 )
300 );
301 }
302 openstation_mark_intro_seen( $user_id, $slug );
303 return rest_ensure_response(
304 array( 'seenIntros' => openstation_get_seen_intros( $user_id ) )
305 );
306 }
307
308 /**
309 * REST handler for `DELETE /desktop-mode/v1/intros`.
310 *
311 * @return WP_REST_Response
312 */
313 function openstation_rest_clear_seen_intros() {
314 $user_id = get_current_user_id();
315 openstation_clear_seen_intros( $user_id );
316 return rest_ensure_response(
317 array( 'seenIntros' => openstation_get_seen_intros( $user_id ) )
318 );
319 }
320