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