| @@ -1,75 +1,81 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * Desktop Mode — "Seen intros" registry. | |
| 3 | + * OpenStation — "Seen intros" registry. | |
| 4 | 4 | * |
| 5 | - * Tracks which one-time introduction dialogs the current user has | |
| 6 | - * already dismissed, so the shell can show a "what's new in this | |
| 7 | - * native app" dialog the first time a ported native window opens | |
| 8 | - * and never bother the user again afterwards. | |
| 5 | + * Tracks which one-time announcements the current user has already | |
| 6 | + * dismissed, so each is shown once and never bothers them again. | |
| 9 | 7 | * |
| 10 | - * Today the surface is the native Posts window. The same key is | |
| 11 | - * intentionally generic — any future ported native app (Pages, | |
| 12 | - * Comments, Users, Plugins, …) registers its own slug and reuses | |
| 13 | - * this storage. OS Settings → Features exposes a "Reset what's-new | |
| 14 | - * dialogs" button that clears the whole list so the user can see | |
| 15 | - * every intro again from scratch. | |
| 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. | |
| 16 | 18 | * |
| 17 | 19 | * Storage shape: |
| 18 | 20 | * user meta `desktop_mode_seen_intros` → array<string> of slugs. |
| 19 | - * `[ 'posts' ]`, `[ 'posts', 'pages' ]`, etc. Slug values pass | |
| 20 | - * through `sanitize_key()` and the list is capped at 64 entries | |
| 21 | - * so a runaway client cannot bloat user-meta indefinitely. | |
| 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. | |
| 22 | 25 | * |
| 23 | - * @package WPDesktopMode | |
| 24 | - * @since 0.8.0 | |
| 26 | + * @package OpenStation | |
| 25 | 27 | */ |
| 26 | 28 | |
| 27 | 29 | defined( 'ABSPATH' ) || exit; |
| 28 | 30 | |
| 29 | -/** User meta key — see file header for shape. */ | |
| 30 | -const DESKTOP_MODE_SEEN_INTROS_META_KEY = 'desktop_mode_seen_intros'; | |
| 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'; | |
| 31 | 41 | |
| 32 | 42 | /** Hard cap so a malicious client cannot grow the list unbounded. */ |
| 33 | -const DESKTOP_MODE_SEEN_INTROS_MAX = 64; | |
| 43 | +const OPENSTATION_SEEN_INTROS_MAX = 64; | |
| 34 | 44 | |
| 35 | 45 | /** |
| 36 | 46 | * Returns the list of intro slugs the user has dismissed. |
| 37 | 47 | * |
| 38 | - * @since 0.8.0 | |
| 39 | - * | |
| 40 | 48 | * @param int $user_id User ID. |
| 41 | 49 | * @return string[] Sanitized list (may be empty). |
| 42 | 50 | */ |
| 43 | -function desktop_mode_get_seen_intros( $user_id ) { | |
| 51 | +function openstation_get_seen_intros( $user_id ) { | |
| 44 | 52 | $user_id = (int) $user_id; |
| 45 | 53 | if ( $user_id <= 0 ) { |
| 46 | 54 | return array(); |
| 47 | 55 | } |
| 48 | 56 | |
| 49 | - $raw = get_user_meta( $user_id, DESKTOP_MODE_SEEN_INTROS_META_KEY, true ); | |
| 57 | + $raw = get_user_meta( $user_id, OPENSTATION_SEEN_INTROS_META_KEY, true ); | |
| 50 | 58 | if ( ! is_array( $raw ) ) { |
| 51 | 59 | return array(); |
| 52 | 60 | } |
| 53 | 61 | |
| 54 | - return desktop_mode_sanitize_seen_intros( $raw ); | |
| 62 | + return openstation_sanitize_seen_intros( $raw ); | |
| 55 | 63 | } |
| 56 | 64 | |
| 57 | 65 | /** |
| 58 | 66 | * Whether the user has already dismissed the given intro. |
| 59 | 67 | * |
| 60 | - * @since 0.8.0 | |
| 61 | - * | |
| 62 | 68 | * @param int $user_id User ID. |
| 63 | 69 | * @param string $slug Intro slug (e.g. `'posts'`). |
| 64 | 70 | * @return bool |
| 65 | 71 | */ |
| 66 | -function desktop_mode_has_seen_intro( $user_id, $slug ) { | |
| 72 | +function openstation_has_seen_intro( $user_id, $slug ) { | |
| 67 | 73 | $slug = sanitize_key( (string) $slug ); |
| 68 | 74 | if ( '' === $slug ) { |
| 69 | 75 | return false; |
| 70 | 76 | } |
| 71 | - return in_array( $slug, desktop_mode_get_seen_intros( $user_id ), true ); | |
| 77 | + return in_array( $slug, openstation_get_seen_intros( $user_id ), true ); | |
| 72 | 78 | } |
| 73 | 79 | |
| 74 | 80 | /** |
| 75 | 81 | * Adds a slug to the user's seen-intros list. |
| @@ -74,17 +80,16 @@ | ||
| 74 | 80 | /** |
| 75 | 81 | * Adds a slug to the user's seen-intros list. |
| 76 | 82 | * |
| 77 | 83 | * Idempotent — re-marking an already-seen intro is a no-op that |
| 78 | - * still returns true. | |
| 84 | + * still returns true. A slug that supersedes others | |
| 85 | + * ({@see openstation_seen_intros_superseded_by()}) removes them first. | |
| 79 | 86 | * |
| 80 | - * @since 0.8.0 | |
| 81 | - * | |
| 82 | 87 | * @param int $user_id User ID. |
| 83 | 88 | * @param string $slug Intro slug. |
| 84 | 89 | * @return bool True on successful write (or no-op), false otherwise. |
| 85 | 90 | */ |
| 86 | -function desktop_mode_mark_intro_seen( $user_id, $slug ) { | |
| 91 | +function openstation_mark_intro_seen( $user_id, $slug ) { | |
| 87 | 92 | $user_id = (int) $user_id; |
| 88 | 93 | $slug = sanitize_key( (string) $slug ); |
| 89 | 94 | if ( $user_id <= 0 || '' === $slug ) { |
| 90 | 95 | return false; |
| @@ -89,49 +94,69 @@ | ||
| 89 | 94 | if ( $user_id <= 0 || '' === $slug ) { |
| 90 | 95 | return false; |
| 91 | 96 | } |
| 92 | 97 | |
| 93 | - $current = desktop_mode_get_seen_intros( $user_id ); | |
| 94 | - if ( in_array( $slug, $current, true ) ) { | |
| 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 ) ) { | |
| 95 | 101 | return true; |
| 96 | 102 | } |
| 97 | 103 | |
| 98 | - $current[] = $slug; | |
| 99 | - $current = array_slice( $current, 0, DESKTOP_MODE_SEEN_INTROS_MAX ); | |
| 104 | + if ( ! in_array( $slug, $kept, true ) ) { | |
| 105 | + $kept[] = $slug; | |
| 106 | + } | |
| 107 | + $kept = array_slice( $kept, 0, OPENSTATION_SEEN_INTROS_MAX ); | |
| 100 | 108 | |
| 101 | 109 | return false !== update_user_meta( |
| 102 | 110 | $user_id, |
| 103 | - DESKTOP_MODE_SEEN_INTROS_META_KEY, | |
| 104 | - $current | |
| 111 | + OPENSTATION_SEEN_INTROS_META_KEY, | |
| 112 | + $kept | |
| 105 | 113 | ); |
| 106 | 114 | } |
| 107 | 115 | |
| 108 | 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 | +/** | |
| 109 | 138 | * Wipes every seen-intro entry for the user. Used by the OS |
| 110 | 139 | * Settings → Features "Reset what's-new dialogs" button. |
| 111 | 140 | * |
| 112 | - * @since 0.8.0 | |
| 113 | - * | |
| 114 | 141 | * @param int $user_id User ID. |
| 115 | 142 | * @return bool True on success. |
| 116 | 143 | */ |
| 117 | -function desktop_mode_clear_seen_intros( $user_id ) { | |
| 144 | +function openstation_clear_seen_intros( $user_id ) { | |
| 118 | 145 | $user_id = (int) $user_id; |
| 119 | 146 | if ( $user_id <= 0 ) { |
| 120 | 147 | return false; |
| 121 | 148 | } |
| 122 | - return (bool) delete_user_meta( $user_id, DESKTOP_MODE_SEEN_INTROS_META_KEY ); | |
| 149 | + return (bool) delete_user_meta( $user_id, OPENSTATION_SEEN_INTROS_META_KEY ); | |
| 123 | 150 | } |
| 124 | 151 | |
| 125 | 152 | /** |
| 126 | 153 | * Coerces a raw payload to a clean list of slugs. |
| 127 | 154 | * |
| 128 | - * @since 0.8.0 | |
| 129 | - * | |
| 130 | 155 | * @param mixed $raw Raw value. |
| 131 | 156 | * @return string[] |
| 132 | 157 | */ |
| 133 | -function desktop_mode_sanitize_seen_intros( $raw ) { | |
| 158 | +function openstation_sanitize_seen_intros( $raw ) { | |
| 134 | 159 | if ( ! is_array( $raw ) ) { |
| 135 | 160 | return array(); |
| 136 | 161 | } |
| 137 | 162 | $out = array(); |
| @@ -144,9 +169,9 @@ | ||
| 144 | 169 | continue; |
| 145 | 170 | } |
| 146 | 171 | $out[] = $slug; |
| 147 | 172 | } |
| 148 | - return array_slice( array_values( array_unique( $out ) ), 0, DESKTOP_MODE_SEEN_INTROS_MAX ); | |
| 173 | + return array_slice( array_values( array_unique( $out ) ), 0, OPENSTATION_SEEN_INTROS_MAX ); | |
| 149 | 174 | } |
| 150 | 175 | |
| 151 | 176 | /** |
| 152 | 177 | * Registers REST routes for the seen-intros surface. |
| @@ -156,19 +181,17 @@ | ||
| 156 | 181 | * DELETE /desktop-mode/v1/intros no body — clears the list |
| 157 | 182 | * |
| 158 | 183 | * Both return the post-mutation list so the client can refresh its |
| 159 | 184 | * local snapshot without a follow-up GET. |
| 160 | - * | |
| 161 | - * @since 0.8.0 | |
| 162 | 185 | */ |
| 163 | -function desktop_mode_register_seen_intros_routes() { | |
| 186 | +function openstation_register_seen_intros_routes() { | |
| 164 | 187 | register_rest_route( |
| 165 | 188 | 'desktop-mode/v1', |
| 166 | 189 | '/intros/seen', |
| 167 | 190 | array( |
| 168 | 191 | 'methods' => WP_REST_Server::CREATABLE, |
| 169 | - 'callback' => 'desktop_mode_rest_mark_intro_seen', | |
| 170 | - 'permission_callback' => 'desktop_mode_rest_seen_intros_permission', | |
| 192 | + 'callback' => 'openstation_rest_mark_intro_seen', | |
| 193 | + 'permission_callback' => 'openstation_rest_seen_intros_permission', | |
| 171 | 194 | 'args' => array( |
| 172 | 195 | 'slug' => array( |
| 173 | 196 | 'required' => true, |
| 174 | 197 | 'type' => 'string', |
| @@ -181,48 +204,105 @@ | ||
| 181 | 204 | 'desktop-mode/v1', |
| 182 | 205 | '/intros', |
| 183 | 206 | array( |
| 184 | 207 | 'methods' => WP_REST_Server::DELETABLE, |
| 185 | - 'callback' => 'desktop_mode_rest_clear_seen_intros', | |
| 186 | - 'permission_callback' => 'desktop_mode_rest_seen_intros_permission', | |
| 208 | + 'callback' => 'openstation_rest_clear_seen_intros', | |
| 209 | + 'permission_callback' => 'openstation_rest_seen_intros_permission', | |
| 187 | 210 | ) |
| 188 | 211 | ); |
| 189 | 212 | } |
| 190 | -add_action( 'rest_api_init', 'desktop_mode_register_seen_intros_routes' ); | |
| 213 | +add_action( 'rest_api_init', 'openstation_register_seen_intros_routes' ); | |
| 191 | 214 | |
| 192 | 215 | /** |
| 193 | - * Permission gate — any logged-in user may manage their own seen- | |
| 194 | - * intros list. | |
| 216 | + * The intro slugs whose dismissal is accepted from an account that has | |
| 217 | + * NOT enabled OpenStation. | |
| 195 | 218 | * |
| 196 | - * @since 0.8.0 | |
| 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. | |
| 197 | 224 | * |
| 198 | - * @return bool | |
| 225 | + * @return string[] | |
| 199 | 226 | */ |
| 200 | -function desktop_mode_rest_seen_intros_permission() { | |
| 201 | - return is_user_logged_in() && current_user_can( 'read' ); | |
| 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; | |
| 202 | 236 | } |
| 203 | 237 | |
| 204 | 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 | +/** | |
| 205 | 287 | * REST handler for `POST /desktop-mode/v1/intros/seen`. |
| 206 | 288 | * |
| 207 | - * @since 0.8.0 | |
| 208 | - * | |
| 209 | 289 | * @param WP_REST_Request $request REST request. |
| 210 | 290 | * @return WP_REST_Response|WP_Error |
| 211 | 291 | */ |
| 212 | -function desktop_mode_rest_mark_intro_seen( WP_REST_Request $request ) { | |
| 292 | +function openstation_rest_mark_intro_seen( WP_REST_Request $request ) { | |
| 213 | 293 | $user_id = get_current_user_id(); |
| 214 | 294 | $slug = sanitize_key( (string) $request->get_param( 'slug' ) ); |
| 215 | 295 | if ( '' === $slug ) { |
| 216 | 296 | return new WP_Error( |
| 217 | - 'desktop_mode_invalid_intro_slug', | |
| 297 | + 'openstation_invalid_intro_slug', | |
| 218 | 298 | __( 'The `slug` parameter must be a non-empty string.', 'desktop-mode' ), |
| 219 | 299 | array( 'status' => 400 ) |
| 220 | 300 | ); |
| 221 | 301 | } |
| 222 | - desktop_mode_mark_intro_seen( $user_id, $slug ); | |
| 302 | + openstation_mark_intro_seen( $user_id, $slug ); | |
| 223 | 303 | return rest_ensure_response( |
| 224 | - array( 'seenIntros' => desktop_mode_get_seen_intros( $user_id ) ) | |
| 304 | + array( 'seenIntros' => openstation_get_seen_intros( $user_id ) ) | |
| 225 | 305 | ); |
| 226 | 306 | } |
| 227 | 307 | |
| 228 | 308 | /** |
| @@ -227,15 +307,13 @@ | ||
| 227 | 307 | |
| 228 | 308 | /** |
| 229 | 309 | * REST handler for `DELETE /desktop-mode/v1/intros`. |
| 230 | 310 | * |
| 231 | - * @since 0.8.0 | |
| 232 | - * | |
| 233 | 311 | * @return WP_REST_Response |
| 234 | 312 | */ |
| 235 | -function desktop_mode_rest_clear_seen_intros() { | |
| 313 | +function openstation_rest_clear_seen_intros() { | |
| 236 | 314 | $user_id = get_current_user_id(); |
| 237 | - desktop_mode_clear_seen_intros( $user_id ); | |
| 315 | + openstation_clear_seen_intros( $user_id ); | |
| 238 | 316 | return rest_ensure_response( |
| 239 | - array( 'seenIntros' => desktop_mode_get_seen_intros( $user_id ) ) | |
| 317 | + array( 'seenIntros' => openstation_get_seen_intros( $user_id ) ) | |
| 240 | 318 | ); |
| 241 | 319 | } |