Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.48% covered (warning)
81.48%
88 / 108
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Subscribers_Announcement
81.48% covered (warning)
81.48%
88 / 108
61.54% covered (warning)
61.54%
8 / 13
37.10
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 is_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_load_wp_build
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
14.08
 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.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
4
 add_wp_admin_submenu
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 on_page_load
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 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     * Register request handlers and the wp-build loader.
75     *
76     * Called from Settings::init_hooks() so the AJAX/admin-post handlers exist
77     * on admin-ajax.php / admin-post.php requests, where `admin_menu` (and so
78     * add_menu()) never fires.
79     *
80     * @return void
81     */
82    public static function init() {
83        add_action( 'wp_ajax_' . self::TOGGLE_ACTION, array( __CLASS__, 'handle_toggle_menu' ) );
84        add_action( 'admin_post_' . self::GO_ACTION, array( __CLASS__, 'handle_go_to_newsletter' ) );
85
86        // Priority 1 mirrors Settings::maybe_load_wp_build(): the modernization
87        // filter has been registered by opt-in code by then, and the wp-build
88        // render function must exist before menu callbacks are resolved.
89        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
90    }
91
92    /**
93     * Whether the announcement page feature is active.
94     *
95     * @return bool
96     */
97    public static function is_enabled() {
98        // The default must match the menu-registration call sites
99        // (subscriptions.php, wpcom-admin-menu.php) and Settings::is_modernized(),
100        // which all default to `true`. If this defaulted to `false` while the menu
101        // is registered, maybe_load_wp_build() would bail, the wp-build render
102        // function would never be defined, and add_menu() would serve the bare
103        // render_fallback() page instead of the styled announcement app.
104        /** This filter is documented in projects/packages/newsletter/src/class-settings.php */
105        return (bool) apply_filters( Settings::MODERNIZATION_FILTER, true );
106    }
107
108    /**
109     * Load wp-build for the announcement page when the feature is enabled.
110     *
111     * @return void
112     */
113    public static function maybe_load_wp_build() {
114        if ( ! self::is_enabled() || ! self::is_announcement_request() ) {
115            return;
116        }
117
118        $build_index = dirname( __DIR__ ) . '/build/build.php';
119
120        if ( ! file_exists( $build_index ) ) {
121            return;
122        }
123
124        require_once $build_index;
125
126        \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(
127            'jetpack-newsletter',
128            array_merge(
129                \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES,
130                \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS
131            )
132        );
133
134        add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
135    }
136
137    /**
138     * Alias the current screen ID to satisfy wp-build's auto-generated enqueue check.
139     *
140     * Mirrors Settings::alias_screen_id_for_wp_build(): wp-build enqueues only
141     * when the screen ID matches the wp-build page name, while our menu slug
142     * stays `jetpack-subscribers`.
143     *
144     * @param \WP_Screen|null $screen The current screen object (passed by WP).
145     * @return void
146     */
147    public static function alias_screen_id_for_wp_build( $screen ) {
148        if ( ! is_object( $screen ) ) {
149            return;
150        }
151
152        $screen->id = self::WP_BUILD_PAGE;
153    }
154
155    /**
156     * Register the Subscribers announcement page under the Jetpack menu.
157     *
158     * When the user opted to remove the menu item, the page stays registered
159     * (so the page remains reachable directly and the choice can be undone)
160     * but the sidebar entry is removed.
161     *
162     * @return void
163     */
164    public static function add_menu() {
165        $callback = function_exists( 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page' )
166            ? 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page'
167            : array( __CLASS__, 'render_fallback' );
168
169        if ( get_option( self::REMOVED_OPTION ) ) {
170            // Register as a hidden page (empty parent slug): it stays reachable
171            // at its URL β€” so the choice can be undone from the page itself β€”
172            // but never appears in the sidebar.
173            $page_suffix = add_submenu_page(
174                '',
175                __( 'Subscribers', 'jetpack-newsletter' ),
176                __( 'Subscribers', 'jetpack-newsletter' ),
177                'manage_options',
178                self::PAGE_SLUG,
179                $callback
180            );
181        } else {
182            $page_suffix = Admin_Menu::add_menu(
183                __( 'Subscribers', 'jetpack-newsletter' ),
184                __( 'Subscribers', 'jetpack-newsletter' ),
185                'manage_options',
186                self::PAGE_SLUG,
187                $callback,
188                15
189            );
190        }
191
192        if ( $page_suffix ) {
193            add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_page_load' ) );
194        }
195    }
196
197    /**
198     * Register the announcement page directly under the Jetpack menu.
199     *
200     * Used on WordPress.com (Simple and WoA), where jetpack-mu-wpcom's
201     * wpcom-admin-menu owns the Jetpack menu and registers submenus with the
202     * core add_submenu_page() at a late priority β€” not the standalone plugin's
203     * Admin_Menu wrapper. Mirrors Settings::add_wp_admin_submenu().
204     *
205     * As in add_menu(), an empty parent slug keeps the page reachable at its URL
206     * (so the "remove from sidebar" choice can be undone) while hiding it from
207     * the sidebar.
208     *
209     * @return void
210     */
211    public static function add_wp_admin_submenu() {
212        $callback = function_exists( 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page' )
213            ? 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page'
214            : array( __CLASS__, 'render_fallback' );
215
216        $parent_slug = get_option( self::REMOVED_OPTION ) ? '' : 'jetpack';
217
218        $page_suffix = add_submenu_page(
219            $parent_slug,
220            __( 'Subscribers', 'jetpack-newsletter' ),
221            __( 'Subscribers', 'jetpack-newsletter' ),
222            'manage_options',
223            self::PAGE_SLUG,
224            $callback
225        );
226
227        if ( $page_suffix ) {
228            add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_page_load' ) );
229        }
230    }
231
232    /**
233     * Page-load actions: record the page view and expose the app data.
234     *
235     * @return void
236     */
237    public static function on_page_load() {
238        add_action( 'admin_head', array( __CLASS__, 'print_app_data' ) );
239
240        self::tracking()->record_user_event(
241            'subscribers_announcement_page_view',
242            array( 'menu_removed' => (bool) get_option( self::REMOVED_OPTION ) )
243        );
244    }
245
246    /**
247     * Print the data the announcement app needs (URLs, nonce, current state).
248     *
249     * @return void
250     */
251    public static function print_app_data() {
252        $data = array(
253            'ajaxUrl'           => admin_url( 'admin-ajax.php' ),
254            'toggleAction'      => self::TOGGLE_ACTION,
255            'toggleNonce'       => wp_create_nonce( self::TOGGLE_ACTION ),
256            // Built with add_query_arg (not wp_nonce_url, which HTML-escapes
257            // the ampersands) because the app navigates to it via JS.
258            'goToNewsletterUrl' => add_query_arg(
259                array(
260                    'action'   => self::GO_ACTION,
261                    '_wpnonce' => wp_create_nonce( self::GO_ACTION ),
262                ),
263                admin_url( 'admin-post.php' )
264            ),
265            'menuRemoved'       => (bool) get_option( self::REMOVED_OPTION ),
266            'menuSlug'          => self::PAGE_SLUG,
267        );
268
269        printf(
270            '<script>window.JetpackSubscribersAnnouncementData = %s;</script>',
271            wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT )
272        );
273    }
274
275    /**
276     * Minimal fallback when the wp-build bundle is unavailable.
277     *
278     * @return void
279     */
280    public static function render_fallback() {
281        ?>
282        <div class="wrap">
283            <h1><?php esc_html_e( 'Subscribers moved', 'jetpack-newsletter' ); ?></h1>
284            <p><?php esc_html_e( 'Now it’s part of Jetpack β†’ Newsletter.', 'jetpack-newsletter' ); ?></p>
285            <p>
286                <a class="button button-primary" href="<?php echo esc_url( Urls::get_newsletter_settings_url() ); ?>">
287                    <?php esc_html_e( 'Take me to Newsletter', 'jetpack-newsletter' ); ?>
288                </a>
289            </p>
290        </div>
291        <?php
292    }
293
294    /**
295     * AJAX handler persisting the "remove Subscribers from the sidebar" choice.
296     *
297     * @return void
298     */
299    public static function handle_toggle_menu() {
300        check_ajax_referer( self::TOGGLE_ACTION );
301
302        if ( ! current_user_can( 'manage_options' ) || ! self::is_enabled() ) {
303            wp_send_json_error( 'unauthorized', 403, JSON_HEX_TAG | JSON_HEX_AMP );
304        }
305
306        $removed = isset( $_POST['removed'] ) && '1' === $_POST['removed'];
307        update_option( self::REMOVED_OPTION, $removed ? 1 : 0, false );
308
309        self::tracking()->record_user_event(
310            'subscribers_announcement_remove_menu_click',
311            array( 'removed' => $removed )
312        );
313
314        wp_send_json_success( array( 'removed' => $removed ), 200, JSON_HEX_TAG | JSON_HEX_AMP );
315    }
316
317    /**
318     * Admin-post handler recording the "Take me to Newsletter" click, then redirecting.
319     *
320     * Tracking the click server-side before the redirect avoids relying on a
321     * JS tracking pipeline on a page that is otherwise static.
322     *
323     * @return never
324     */
325    public static function handle_go_to_newsletter() {
326        check_admin_referer( self::GO_ACTION );
327
328        if ( current_user_can( 'manage_options' ) && self::is_enabled() ) {
329            self::tracking()->record_user_event( 'subscribers_announcement_newsletter_click' );
330        }
331
332        wp_safe_redirect( admin_url( 'admin.php?page=' . Settings::ADMIN_PAGE_SLUG ) );
333        exit( 0 );
334    }
335
336    /**
337     * Get a Tracking instance.
338     *
339     * The product name stays `jetpack` so the events are recorded as
340     * `jetpack_subscribers_announcement_*` regardless of which plugin
341     * bundles this package.
342     *
343     * @return Tracking
344     */
345    private static function tracking() {
346        return new Tracking( 'jetpack', new Connection_Manager( 'jetpack' ) );
347    }
348
349    /**
350     * Returns true when the current request targets the announcement page.
351     *
352     * @return bool
353     */
354    private static function is_announcement_request() {
355        if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
356            return false;
357        }
358
359        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
360    }
361}