Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
80.33% |
98 / 122 |
|
57.14% |
8 / 14 |
CRAP | |
0.00% |
0 / 1 |
| Subscribers_Announcement | |
80.33% |
98 / 122 |
|
57.14% |
8 / 14 |
50.58 | |
0.00% |
0 / 1 |
| init | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| is_enabled | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| maybe_load_wp_build | |
12.50% |
2 / 16 |
|
0.00% |
0 / 1 |
21.75 | |||
| alias_screen_id_for_wp_build | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| add_menu | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
5 | |||
| add_wp_admin_submenu | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
5.01 | |||
| on_page_load | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| enqueue_i18n_loader | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| print_app_data | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
1 | |||
| render_fallback | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| handle_toggle_menu | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| handle_go_to_newsletter | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| tracking | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_announcement_request | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Transitional "Subscribers moved" announcement page. |
| 4 | * |
| 5 | * When the Newsletter modernization filter is enabled, the unified |
| 6 | * Jetpack → Newsletter page owns subscriber management and the legacy |
| 7 | * "Subscribers ↗" Calypso shortcut is retired. Instead of silently dropping |
| 8 | * the menu item, this page takes its place so people who rely on the link |
| 9 | * learn the new location before it disappears. They can also remove the |
| 10 | * menu item themselves once they have adopted the new flow. |
| 11 | * |
| 12 | * The whole feature is temporary and kept deliberately small: this class |
| 13 | * (menu, handlers, tracking) plus the `routes/subscribers-announcement` |
| 14 | * wp-build route can be deleted wholesale once the transition period ends. |
| 15 | * |
| 16 | * @package automattic/jetpack-newsletter |
| 17 | */ |
| 18 | |
| 19 | namespace Automattic\Jetpack\Newsletter; |
| 20 | |
| 21 | use Automattic\Jetpack\Admin_UI\Admin_Menu; |
| 22 | use Automattic\Jetpack\Connection\Manager as Connection_Manager; |
| 23 | use Automattic\Jetpack\Tracking; |
| 24 | |
| 25 | /** |
| 26 | * Renders the transitional Subscribers announcement page and handles its |
| 27 | * "remove from sidebar" toggle and "Take me to Newsletter" redirect. |
| 28 | * |
| 29 | * The menu itself is registered by callers that own the Subscribers menu |
| 30 | * placement (the Jetpack plugin's subscriptions module) via add_menu(); |
| 31 | * this class self-registers only request handlers and wp-build loading. |
| 32 | * |
| 33 | * @since 0.10.0 |
| 34 | */ |
| 35 | class Subscribers_Announcement { |
| 36 | |
| 37 | /** |
| 38 | * Admin page slug (kept distinct from the wp-build page name; the screen |
| 39 | * ID is aliased so wp-build's enqueue check still matches). |
| 40 | * |
| 41 | * @var string |
| 42 | */ |
| 43 | const PAGE_SLUG = 'jetpack-subscribers'; |
| 44 | |
| 45 | /** |
| 46 | * Wp-build page name, matching `routes/subscribers-announcement/package.json`. |
| 47 | * |
| 48 | * @var string |
| 49 | */ |
| 50 | const WP_BUILD_PAGE = 'jetpack-subscribers-announcement'; |
| 51 | |
| 52 | /** |
| 53 | * Option storing whether the user removed the Subscribers menu item. |
| 54 | * |
| 55 | * @var string |
| 56 | */ |
| 57 | const REMOVED_OPTION = 'jetpack_subscribers_announcement_menu_removed'; |
| 58 | |
| 59 | /** |
| 60 | * AJAX action toggling the menu item visibility. |
| 61 | * |
| 62 | * @var string |
| 63 | */ |
| 64 | const TOGGLE_ACTION = 'jetpack_subscribers_announcement_toggle_menu'; |
| 65 | |
| 66 | /** |
| 67 | * Admin-post action tracking the "Take me to Newsletter" click before redirecting. |
| 68 | * |
| 69 | * @var string |
| 70 | */ |
| 71 | const GO_ACTION = 'jetpack_subscribers_announcement_go_to_newsletter'; |
| 72 | |
| 73 | /** |
| 74 | * Site ID cutoff for displaying the Subscribers announcement page. |
| 75 | * |
| 76 | * Sites with an ID *below* this value registered before Subscribers moved |
| 77 | * out of its old placement, so they need the transitional announcement. |
| 78 | * Sites at or above it only ever saw the current placement. The bound is |
| 79 | * exclusive: a site whose ID equals this value does not see the page. |
| 80 | * |
| 81 | * @var int |
| 82 | */ |
| 83 | const SITE_ID_CUTOFF = 256340000; |
| 84 | |
| 85 | /** |
| 86 | * Register request handlers and the wp-build loader. |
| 87 | * |
| 88 | * Called from Settings::init_hooks() so the AJAX/admin-post handlers exist |
| 89 | * on admin-ajax.php / admin-post.php requests, where `admin_menu` (and so |
| 90 | * add_menu()) never fires. |
| 91 | * |
| 92 | * @return void |
| 93 | */ |
| 94 | public static function init() { |
| 95 | if ( ! self::is_enabled() ) { |
| 96 | return; |
| 97 | } |
| 98 | |
| 99 | add_action( 'wp_ajax_' . self::TOGGLE_ACTION, array( __CLASS__, 'handle_toggle_menu' ) ); |
| 100 | add_action( 'admin_post_' . self::GO_ACTION, array( __CLASS__, 'handle_go_to_newsletter' ) ); |
| 101 | |
| 102 | // Priority 1 mirrors Settings::maybe_load_wp_build(): the modernization |
| 103 | // filter has been registered by opt-in code by then, and the wp-build |
| 104 | // render function must exist before menu callbacks are resolved. |
| 105 | add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Whether the announcement page feature is active. |
| 110 | * |
| 111 | * This is the single gate for the whole feature: `init()` (handlers and the |
| 112 | * wp-build loader), `maybe_load_wp_build()`, and both menu-registration |
| 113 | * entry points consult it. They must agree — if the menu were registered |
| 114 | * while this returned false, maybe_load_wp_build() would bail, the wp-build |
| 115 | * render function would never be defined, and add_menu() would serve the |
| 116 | * bare render_fallback() page instead of the styled announcement app. |
| 117 | * |
| 118 | * @return bool |
| 119 | */ |
| 120 | public static function is_enabled() { |
| 121 | /** This filter is documented in projects/packages/newsletter/src/class-settings.php */ |
| 122 | if ( ! apply_filters( Settings::MODERNIZATION_FILTER, true ) ) { |
| 123 | return false; |
| 124 | } |
| 125 | |
| 126 | // Only sites predating the Subscribers move have anything to be told |
| 127 | // about. Use the quiet lookup: it returns null rather than a WP_Error |
| 128 | // when the site has no known ID, and comparing a WP_Error against an |
| 129 | // int is a TypeError on PHP 8. A site with no known ID (disconnected) |
| 130 | // is treated as newer and does not see the page. |
| 131 | $site_id = Connection_Manager::get_site_id( true ); |
| 132 | |
| 133 | return $site_id !== null && $site_id < self::SITE_ID_CUTOFF; |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Load wp-build for the announcement page when the feature is enabled. |
| 138 | * |
| 139 | * @return void |
| 140 | */ |
| 141 | public static function maybe_load_wp_build() { |
| 142 | if ( ! self::is_enabled() || ! self::is_announcement_request() ) { |
| 143 | return; |
| 144 | } |
| 145 | |
| 146 | $build_index = dirname( __DIR__ ) . '/build/build.php'; |
| 147 | |
| 148 | if ( ! file_exists( $build_index ) ) { |
| 149 | return; |
| 150 | } |
| 151 | |
| 152 | require_once $build_index; |
| 153 | |
| 154 | \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( |
| 155 | 'jetpack-newsletter', |
| 156 | array_merge( |
| 157 | \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES, |
| 158 | \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS |
| 159 | ) |
| 160 | ); |
| 161 | |
| 162 | // wp-build registers standalone modules (e.g. the init module) on |
| 163 | // wp_default_scripts, which has already fired by admin_menu. Register them |
| 164 | // directly so the init module makes it into the import map. |
| 165 | if ( function_exists( 'jetpack_newsletter_register_script_modules' ) ) { |
| 166 | jetpack_newsletter_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes. |
| 167 | } |
| 168 | |
| 169 | add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) ); |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Alias the current screen ID to satisfy wp-build's auto-generated enqueue check. |
| 174 | * |
| 175 | * Mirrors Settings::alias_screen_id_for_wp_build(): wp-build enqueues only |
| 176 | * when the screen ID matches the wp-build page name, while our menu slug |
| 177 | * stays `jetpack-subscribers`. |
| 178 | * |
| 179 | * @param \WP_Screen|null $screen The current screen object (passed by WP). |
| 180 | * @return void |
| 181 | */ |
| 182 | public static function alias_screen_id_for_wp_build( $screen ) { |
| 183 | if ( ! is_object( $screen ) ) { |
| 184 | return; |
| 185 | } |
| 186 | |
| 187 | $screen->id = self::WP_BUILD_PAGE; |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Register the Subscribers announcement page under the Jetpack menu. |
| 192 | * |
| 193 | * When the user opted to remove the menu item, the page stays registered |
| 194 | * (so the page remains reachable directly and the choice can be undone) |
| 195 | * but the sidebar entry is removed. |
| 196 | * |
| 197 | * @return void |
| 198 | */ |
| 199 | public static function add_menu() { |
| 200 | if ( ! self::is_enabled() ) { |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | $callback = function_exists( 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page' ) |
| 205 | ? 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page' |
| 206 | : array( __CLASS__, 'render_fallback' ); |
| 207 | |
| 208 | if ( get_option( self::REMOVED_OPTION ) ) { |
| 209 | // Register as a hidden page (empty parent slug): it stays reachable |
| 210 | // at its URL — so the choice can be undone from the page itself — |
| 211 | // but never appears in the sidebar. |
| 212 | $page_suffix = add_submenu_page( |
| 213 | '', |
| 214 | __( 'Subscribers', 'jetpack-newsletter' ), |
| 215 | __( 'Subscribers', 'jetpack-newsletter' ), |
| 216 | 'manage_options', |
| 217 | self::PAGE_SLUG, |
| 218 | $callback |
| 219 | ); |
| 220 | } else { |
| 221 | $page_suffix = Admin_Menu::add_menu( |
| 222 | __( 'Subscribers', 'jetpack-newsletter' ), |
| 223 | __( 'Subscribers', 'jetpack-newsletter' ), |
| 224 | 'manage_options', |
| 225 | self::PAGE_SLUG, |
| 226 | $callback, |
| 227 | 15 |
| 228 | ); |
| 229 | } |
| 230 | |
| 231 | if ( $page_suffix ) { |
| 232 | add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_page_load' ) ); |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Register the announcement page directly under the Jetpack menu. |
| 238 | * |
| 239 | * Used on WordPress.com (Simple and WoA), where jetpack-mu-wpcom's |
| 240 | * wpcom-admin-menu owns the Jetpack menu and registers submenus with the |
| 241 | * core add_submenu_page() at a late priority — not the standalone plugin's |
| 242 | * Admin_Menu wrapper. Mirrors Settings::add_wp_admin_submenu(). |
| 243 | * |
| 244 | * As in add_menu(), an empty parent slug keeps the page reachable at its URL |
| 245 | * (so the "remove from sidebar" choice can be undone) while hiding it from |
| 246 | * the sidebar. |
| 247 | * |
| 248 | * @return void |
| 249 | */ |
| 250 | public static function add_wp_admin_submenu() { |
| 251 | if ( ! self::is_enabled() ) { |
| 252 | return; |
| 253 | } |
| 254 | |
| 255 | $callback = function_exists( 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page' ) |
| 256 | ? 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page' |
| 257 | : array( __CLASS__, 'render_fallback' ); |
| 258 | |
| 259 | $parent_slug = get_option( self::REMOVED_OPTION ) ? '' : 'jetpack'; |
| 260 | |
| 261 | $page_suffix = add_submenu_page( |
| 262 | $parent_slug, |
| 263 | __( 'Subscribers', 'jetpack-newsletter' ), |
| 264 | __( 'Subscribers', 'jetpack-newsletter' ), |
| 265 | 'manage_options', |
| 266 | self::PAGE_SLUG, |
| 267 | $callback |
| 268 | ); |
| 269 | |
| 270 | if ( $page_suffix ) { |
| 271 | add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_page_load' ) ); |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | /** |
| 276 | * Page-load actions: record the page view and expose the app data. |
| 277 | * |
| 278 | * @return void |
| 279 | */ |
| 280 | public static function on_page_load() { |
| 281 | add_action( 'admin_head', array( __CLASS__, 'print_app_data' ) ); |
| 282 | add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_i18n_loader' ) ); |
| 283 | |
| 284 | self::tracking()->record_user_event( |
| 285 | 'subscribers_announcement_page_view', |
| 286 | array( 'menu_removed' => (bool) get_option( self::REMOVED_OPTION ) ) |
| 287 | ); |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * Enqueue the i18n loader so the wp-build init module can download its JS |
| 292 | * translation catalogs. It's registered on every admin page by jetpack-assets |
| 293 | * but only enqueued when depended on; the esbuild bundles don't pull it in. |
| 294 | * |
| 295 | * @return void |
| 296 | */ |
| 297 | public static function enqueue_i18n_loader() { |
| 298 | if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) { |
| 299 | wp_enqueue_script( 'wp-jp-i18n-loader' ); |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | /** |
| 304 | * Print the data the announcement app needs (URLs, nonce, current state). |
| 305 | * |
| 306 | * @return void |
| 307 | */ |
| 308 | public static function print_app_data() { |
| 309 | $data = array( |
| 310 | 'ajaxUrl' => admin_url( 'admin-ajax.php' ), |
| 311 | 'toggleAction' => self::TOGGLE_ACTION, |
| 312 | 'toggleNonce' => wp_create_nonce( self::TOGGLE_ACTION ), |
| 313 | // Built with add_query_arg (not wp_nonce_url, which HTML-escapes |
| 314 | // the ampersands) because the app navigates to it via JS. |
| 315 | 'goToNewsletterUrl' => add_query_arg( |
| 316 | array( |
| 317 | 'action' => self::GO_ACTION, |
| 318 | '_wpnonce' => wp_create_nonce( self::GO_ACTION ), |
| 319 | ), |
| 320 | admin_url( 'admin-post.php' ) |
| 321 | ), |
| 322 | 'menuRemoved' => (bool) get_option( self::REMOVED_OPTION ), |
| 323 | 'menuSlug' => self::PAGE_SLUG, |
| 324 | ); |
| 325 | |
| 326 | printf( |
| 327 | '<script>window.JetpackSubscribersAnnouncementData = %s;</script>', |
| 328 | wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ) |
| 329 | ); |
| 330 | } |
| 331 | |
| 332 | /** |
| 333 | * Minimal fallback when the wp-build bundle is unavailable. |
| 334 | * |
| 335 | * @return void |
| 336 | */ |
| 337 | public static function render_fallback() { |
| 338 | ?> |
| 339 | <div class="wrap"> |
| 340 | <h1><?php esc_html_e( 'Subscribers moved', 'jetpack-newsletter' ); ?></h1> |
| 341 | <p><?php esc_html_e( 'Now it’s part of Jetpack → Newsletter.', 'jetpack-newsletter' ); ?></p> |
| 342 | <p> |
| 343 | <a class="button button-primary" href="<?php echo esc_url( Urls::get_newsletter_settings_url() ); ?>"> |
| 344 | <?php esc_html_e( 'Take me to Newsletter', 'jetpack-newsletter' ); ?> |
| 345 | </a> |
| 346 | </p> |
| 347 | </div> |
| 348 | <?php |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * AJAX handler persisting the "remove Subscribers from the sidebar" choice. |
| 353 | * |
| 354 | * @return void |
| 355 | */ |
| 356 | public static function handle_toggle_menu() { |
| 357 | check_ajax_referer( self::TOGGLE_ACTION ); |
| 358 | |
| 359 | if ( ! current_user_can( 'manage_options' ) || ! self::is_enabled() ) { |
| 360 | wp_send_json_error( 'unauthorized', 403, JSON_HEX_TAG | JSON_HEX_AMP ); |
| 361 | } |
| 362 | |
| 363 | $removed = isset( $_POST['removed'] ) && '1' === $_POST['removed']; |
| 364 | update_option( self::REMOVED_OPTION, $removed ? 1 : 0, false ); |
| 365 | |
| 366 | self::tracking()->record_user_event( |
| 367 | 'subscribers_announcement_remove_menu_click', |
| 368 | array( 'removed' => $removed ) |
| 369 | ); |
| 370 | |
| 371 | wp_send_json_success( array( 'removed' => $removed ), 200, JSON_HEX_TAG | JSON_HEX_AMP ); |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * Admin-post handler recording the "Take me to Newsletter" click, then redirecting. |
| 376 | * |
| 377 | * Tracking the click server-side before the redirect avoids relying on a |
| 378 | * JS tracking pipeline on a page that is otherwise static. |
| 379 | * |
| 380 | * @return never |
| 381 | */ |
| 382 | public static function handle_go_to_newsletter() { |
| 383 | check_admin_referer( self::GO_ACTION ); |
| 384 | |
| 385 | if ( current_user_can( 'manage_options' ) && self::is_enabled() ) { |
| 386 | self::tracking()->record_user_event( 'subscribers_announcement_newsletter_click' ); |
| 387 | } |
| 388 | |
| 389 | wp_safe_redirect( admin_url( 'admin.php?page=' . Settings::ADMIN_PAGE_SLUG ) ); |
| 390 | exit( 0 ); |
| 391 | } |
| 392 | |
| 393 | /** |
| 394 | * Get a Tracking instance. |
| 395 | * |
| 396 | * The product name stays `jetpack` so the events are recorded as |
| 397 | * `jetpack_subscribers_announcement_*` regardless of which plugin |
| 398 | * bundles this package. |
| 399 | * |
| 400 | * @return Tracking |
| 401 | */ |
| 402 | private static function tracking() { |
| 403 | return new Tracking( 'jetpack', new Connection_Manager( 'jetpack' ) ); |
| 404 | } |
| 405 | |
| 406 | /** |
| 407 | * Returns true when the current request targets the announcement page. |
| 408 | * |
| 409 | * @return bool |
| 410 | */ |
| 411 | private static function is_announcement_request() { |
| 412 | if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 413 | return false; |
| 414 | } |
| 415 | |
| 416 | return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 417 | } |
| 418 | } |