Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.33% covered (warning)
80.33%
98 / 122
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Subscribers_Announcement
80.33% covered (warning)
80.33%
98 / 122
57.14% covered (warning)
57.14%
8 / 14
50.58
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 is_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 maybe_load_wp_build
12.50% covered (danger)
12.50%
2 / 16
0.00% covered (danger)
0.00%
0 / 1
21.75
 alias_screen_id_for_wp_build
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add_menu
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
5
 add_wp_admin_submenu
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 on_page_load
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_i18n_loader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 print_app_data
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 render_fallback
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 handle_toggle_menu
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 handle_go_to_newsletter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 tracking
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_announcement_request
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
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
19namespace Automattic\Jetpack\Newsletter;
20
21use Automattic\Jetpack\Admin_UI\Admin_Menu;
22use Automattic\Jetpack\Connection\Manager as Connection_Manager;
23use 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 */
35class 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}