Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.20% covered (warning)
57.20%
155 / 271
44.44% covered (danger)
44.44%
12 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Settings
57.20% covered (warning)
57.20%
155 / 271
44.44% covered (danger)
44.44%
12 / 27
555.15
0.00% covered (danger)
0.00%
0 / 1
 register_feature_flags
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 init
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 is_subscriptions_active
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 should_show_menu_item
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 init_hooks
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
4.07
 redirect_retired_subscribers_page
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 maybe_load_wp_build
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 add_wp_admin_menu
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
10
 add_wp_admin_submenu
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 add_admin_shell_member
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 load_as_admin_shell_guest
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 admin_init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_script_data
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 load_admin_scripts
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
4
 get_subscriber_management_url
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
19.47
 render
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_reading_page_notice
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 render_reading_page_notice
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
2
 load_wp_build
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
7.10
 load_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 alias_screen_id_for_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 restore_screen_id_after_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 is_modernized
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_wp_admin_subscriber_management_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_subscribers_url_script_data
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_subscribers_tab_available
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 is_newsletter_admin_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * A class that adds a newsletter settings screen to wp-admin.
4 *
5 * @package automattic/jetpack-newsletter
6 */
7
8namespace Automattic\Jetpack\Newsletter;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Admin_UI\Admin_Shell;
12use Automattic\Jetpack\Assets;
13use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14use Automattic\Jetpack\Feature_Flags\Feature_Flags;
15use Automattic\Jetpack\Modules;
16use Automattic\Jetpack\Redirect;
17use Automattic\Jetpack\Status;
18use Automattic\Jetpack\Status\Host;
19use Jetpack_Tracks_Client;
20
21/**
22 * A class responsible for adding a newsletter settings screen to wp-admin.
23 */
24class Settings {
25
26    const PACKAGE_VERSION = '0.18.5';
27
28    const ADMIN_PAGE_SLUG = 'jetpack-newsletter';
29
30    /**
31     * Slug of the retired Subscribers page, kept only to redirect stale bookmarks.
32     */
33    const RETIRED_SUBSCRIBERS_PAGE_SLUG = 'jetpack-subscribers';
34
35    /**
36     * Filter name that gates the wp-build–based dashboard.
37     *
38     * When this filter returns true, "Jetpack > Newsletter" renders the new
39     * wp-build dashboard instead of the legacy Newsletter Settings React app.
40     */
41    const MODERNIZATION_FILTER = 'rsm_jetpack_ui_modernization_newsletter';
42
43    /**
44     * Feature flag for the Newsletter Overview tab.
45     *
46     * Also gates the Stats tab and its REST endpoints: Stats is a temporary,
47     * standalone page that eases development of the Overview dashboard's
48     * eventual stats section -- it ships and retires with the same flag rather
49     * than getting an independent one.
50     */
51    const OVERVIEW_FEATURE_FLAG = 'newsletter-overview';
52
53    /**
54     * Whether the class has been initialized
55     *
56     * @var boolean
57     */
58    private static $initialized = false;
59
60    /**
61     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
62     *
63     * @var string|null
64     */
65    private static $wp_build_original_screen_id = null;
66
67    /**
68     * Register Newsletter feature flags.
69     *
70     * @return void
71     */
72    public static function register_feature_flags() {
73        Feature_Flags::register(
74            self::OVERVIEW_FEATURE_FLAG,
75            array(
76                'default'     => true,
77                'description' => 'Enable the Newsletter Overview and Stats tabs.',
78                'owner'       => 'jetpack-newsletter',
79            )
80        );
81
82        Subscriber_Stats_Controller::register();
83    }
84
85    /**
86     * Init Newsletter Settings if it wasn't already.
87     */
88    public static function init() {
89        self::register_feature_flags();
90
91        if ( ! self::$initialized ) {
92            self::$initialized = true;
93            ( new self() )->init_hooks();
94        }
95    }
96
97    /**
98     * Check if the subscriptions module is active.
99     *
100     * @return bool
101     */
102    private function is_subscriptions_active() {
103        return ( new Modules() )->is_active( 'subscriptions' );
104    }
105
106    /**
107     * Determine whether to show the Newsletter menu item.
108     * When true, shown regardless of subscriptions module state.
109     *
110     * @return bool
111     */
112    private function should_show_menu_item() {
113        /**
114         * Filter to control Newsletter menu item visibility.
115         * Defaults to true.
116         *
117         * @since 0.6.0
118         * @param bool $show Whether to show the menu item.
119         */
120        return apply_filters(
121            'jetpack_show_newsletter_menu_item',
122            true
123        );
124    }
125
126    /**
127     * Subscribe to necessary hooks.
128     */
129    public function init_hooks() {
130        // Priority 1 so this runs before the menu is built and the request is denied.
131        add_action( 'admin_menu', array( __CLASS__, 'redirect_retired_subscribers_page' ), 1 );
132
133        // Add the Reading settings notice as long as subscriptions are active.
134        if ( $this->is_subscriptions_active() ) {
135            add_action( 'admin_init', array( $this, 'add_reading_page_notice' ) );
136        }
137
138        // Hijack the config URLs to point to our settings page.
139        // Priority 20 to override the default URL set in subscriptions.php.
140        add_filter(
141            'jetpack_module_configuration_url_subscriptions',
142            function () {
143                return Urls::get_newsletter_settings_url();
144            },
145            20
146        );
147
148        // Defer wp-build loading to admin_menu (priority 1) on every host. The
149        // modernization filter â€” which third parties typically register from a
150        // plugins_loaded callback â€” needs to have been applied before we read it,
151        // and the wp-build render function needs to be defined before any menu
152        // callback runs (priority 999 on standalone Jetpack, priority 999999 on
153        // wpcom Simple via wpcom-admin-menu.php's call to add_wp_admin_submenu).
154        // Settings::init() runs synchronously from load-jetpack.php at
155        // plugin-file-include time â€” before any plugins_loaded callback fires â€”
156        // so an inline check here would always see the unfiltered default.
157        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
158
159        if ( class_exists( Admin_Shell::class ) ) {
160            add_filter( 'jetpack_admin_shell_members', array( $this, 'add_admin_shell_member' ) );
161        }
162
163        // Priority 20 runs after add_script_data(), which replaces the whole `newsletter` key on the Newsletter page.
164        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_subscribers_url_script_data' ), 20 );
165
166        $host = new Host();
167
168        // On wpcom Simple, the Jetpack menu is created at priority 999999 by wpcom-admin-menu.php,
169        // which will call add_wp_admin_submenu() directly. Skip adding the menu here to avoid
170        // trying to add a submenu before the parent menu exists.
171        if ( $host->is_wpcom_simple() ) {
172            return;
173        }
174
175        // Add admin menu item.
176        // Use priority 999 to ensure menu items are queued BEFORE Admin_Menu::admin_menu_hook_callback
177        // runs at priority 1000 to process all queued items.
178        add_action( 'admin_menu', array( $this, 'add_wp_admin_menu' ), 999 );
179    }
180
181    /**
182     * Send the retired Subscribers page to Newsletter, which absorbed it.
183     *
184     * The slug was live for about three months, so it is still in browser histories,
185     * where it would otherwise hit WordPress's generic "not allowed to access this
186     * page" and read as a permissions error rather than a move.
187     *
188     * @return void
189     */
190    public static function redirect_retired_subscribers_page() {
191        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
192        $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
193
194        if ( self::RETIRED_SUBSCRIBERS_PAGE_SLUG !== $page || ! current_user_can( 'manage_options' ) ) {
195            return;
196        }
197
198        wp_safe_redirect( admin_url( 'admin.php?page=' . self::ADMIN_PAGE_SLUG ) );
199        exit( 0 );
200    }
201
202    /**
203     * Load wp-build for the Newsletter admin page when modernization is enabled.
204     *
205     * Hooked to `admin_menu` priority 1 so the modernization filter has been
206     * registered by any opt-in code (mu-plugins, snippets, themes) before we
207     * read it, and so the wp-build render function and enqueue hook are in
208     * place before `add_wp_admin_menu` runs at priority 999.
209     *
210     * @return void
211     */
212    public static function maybe_load_wp_build() {
213        if ( ! self::is_modernized() || ! self::is_newsletter_admin_request() ) {
214            return;
215        }
216
217        self::load_wp_build_with_screen_alias();
218    }
219
220    /**
221     * Add the newsletter settings submenu to the Jetpack menu.
222     *
223     * Note: This method is NOT called on wpcom Simple sites. Simple sites use
224     * add_wp_admin_submenu() called from wpcom-admin-menu.php instead.
225     */
226    public function add_wp_admin_menu() {
227        // On sites using Jetpack, only show the menu if the site is connected.
228        if ( ! ( new Connection_Manager() )->is_connected() ) {
229            return;
230        }
231
232        // On the modernized dashboard, the Newsletter screen is only useful when the
233        // subscriptions module is active, so skip registering the menu entirely when it
234        // is off. Gated on the modernization flag to leave legacy behavior unchanged.
235        if ( self::is_modernized() && ! $this->is_subscriptions_active() ) {
236            return;
237        }
238
239        $host = new Host();
240
241        // should_show_menu_item() controls visibility of the menu item.
242        $show_menu   = $this->should_show_menu_item();
243        $parent_slug = $show_menu ? 'jetpack' : '';
244
245        // On Atomic, use add_submenu_page. On standalone Jetpack, use Admin_Menu when showing in menu.
246        $use_jetpack_menu = ! $host->is_woa_site() && $show_menu;
247
248        $callback = self::is_modernized() && function_exists( 'jetpack_newsletter_jetpack_newsletter_dashboard_wp_admin_render_page' )
249            ? 'jetpack_newsletter_jetpack_newsletter_dashboard_wp_admin_render_page'
250            : array( $this, 'render' );
251
252        // Register menu item.
253        if ( $use_jetpack_menu ) {
254            $page_suffix = Admin_Menu::add_menu(
255                /** "Newsletter" is a product name, do not translate. */
256                'Newsletter',
257                'Newsletter',
258                'manage_options',
259                'jetpack-newsletter',
260                $callback,
261                null,
262                array(
263                    'product' => 'newsletter',
264                    'key'     => 'jetpack-newsletter',
265                )
266            );
267        } else {
268            $page_suffix = add_submenu_page(
269                $parent_slug,
270                /** "Newsletter" is a product name, do not translate. */
271                'Newsletter',
272                'Newsletter',
273                'manage_options',
274                'jetpack-newsletter',
275                $callback
276            );
277        }
278
279        if ( $page_suffix ) {
280            add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) );
281        }
282    }
283
284    /**
285     * Add the newsletter settings submenu directly under the Jetpack menu.
286     *
287     * This method is called from wpcom-admin-menu.php on Simple sites at late priority
288     * (999999) when the Jetpack menu already exists.
289     */
290    public function add_wp_admin_submenu() {
291        // On the modernized dashboard, the Newsletter screen is only useful when the
292        // subscriptions module is active, so skip registering the menu entirely when it
293        // is off. Gated on the modernization flag to leave legacy behavior unchanged.
294        if ( self::is_modernized() && ! $this->is_subscriptions_active() ) {
295            return;
296        }
297
298        $parent_slug = $this->should_show_menu_item() ? 'jetpack' : '';
299        $callback    = self::is_modernized() && function_exists( 'jetpack_newsletter_jetpack_newsletter_dashboard_wp_admin_render_page' )
300            ? 'jetpack_newsletter_jetpack_newsletter_dashboard_wp_admin_render_page'
301            : array( $this, 'render' );
302        $page_suffix = add_submenu_page(
303            $parent_slug,
304            /** "Newsletter" is a product name, do not translate. */
305            'Newsletter',
306            'Newsletter',
307            'manage_options',
308            'jetpack-newsletter',
309            $callback
310        );
311
312        if ( $page_suffix ) {
313            add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) );
314        }
315    }
316
317    /**
318     * Register Newsletter with the admin shell, which switches between Jetpack dashboards without a page load.
319     *
320     * @since $$next-version$$
321     *
322     * @param array $members Shell members keyed by admin page slug.
323     * @return array
324     */
325    public function add_admin_shell_member( $members ) {
326        // The same gates add_wp_admin_menu() applies, since admin-ajax never builds the menu.
327        if (
328            ( new Host() )->is_wpcom_simple()
329            || ! file_exists( dirname( __DIR__ ) . '/build/build.php' )
330            || ! self::is_modernized()
331            || ! $this->should_show_menu_item()
332            || ! $this->is_subscriptions_active()
333            || ! ( new Connection_Manager() )->is_connected()
334        ) {
335            return $members;
336        }
337
338        $members[ self::ADMIN_PAGE_SLUG ] = array(
339            'build_prefix' => 'jetpack_newsletter',
340            'build_page'   => 'jetpack-newsletter-dashboard',
341            'init_modules' => array( '@jetpack-newsletter/init' ),
342            /** "Newsletter" is a product name, do not translate. */
343            'title'        => 'Newsletter',
344            'capability'   => 'manage_options',
345            'handles'      => array( 'jetpack-script-data', 'jp-tracks', 'wp-jp-i18n-loader' ),
346            'load'         => array( $this, 'load_as_admin_shell_guest' ),
347        );
348        return $members;
349    }
350
351    /**
352     * Ready the Newsletter routes and page data while another dashboard's page is open, without the screen ID alias.
353     *
354     * @since $$next-version$$
355     *
356     * @return void
357     */
358    public function load_as_admin_shell_guest() {
359        self::load_wp_build();
360        $this->admin_init();
361    }
362
363    /**
364     * Admin init actions.
365     */
366    public function admin_init() {
367        add_filter( 'jetpack_admin_js_script_data', array( $this, 'add_script_data' ) );
368        add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
369    }
370
371    /**
372     * Add newsletter-specific data to the global JetpackScriptData object.
373     *
374     * @param array $data The existing script data.
375     * @return array The modified script data.
376     */
377    public function add_script_data( $data ) {
378        $current_user = wp_get_current_user();
379        $theme        = wp_get_theme();
380
381        $host                   = new Host();
382        $status                 = new Status();
383        $site_suffix            = $status->get_site_suffix();
384        $blog_id                = (int) $host->get_wpcom_site_id();
385        $is_wpcom               = $host->is_wpcom_platform();
386        $is_block_theme         = wp_is_block_theme();
387        $setup_payment_plan_url = ( $is_wpcom ? 'https://wordpress.com/earn/payments/' : 'https://cloud.jetpack.com/monetize/payments/' ) . $site_suffix;
388
389        $wp_admin_subscriber_management_enabled = self::is_wp_admin_subscriber_management_enabled();
390
391        // Populate blog_id which is needed for API calls on Simple sites.
392        $data['site']['wpcom']['blog_id'] = $blog_id;
393
394        // Add newsletter-specific data.
395        // Note: Common data like admin_url, rest_nonce, rest_root, title, is_wpcom_platform,
396        // and user.current_user.display_name are already provided by Script_Data.
397        $data['newsletter'] = array(
398            'isBlockTheme'                    => $is_block_theme,
399            'themeStylesheet'                 => $theme->get_stylesheet(),
400            'email'                           => $current_user->user_email,
401            'gravatar'                        => get_avatar_url( $current_user->ID ),
402            'dateExample'                     => gmdate( get_option( 'date_format' ), time() ),
403            'subscriberManagementUrl'         => $this->get_subscriber_management_url( $wp_admin_subscriber_management_enabled, $is_wpcom, $site_suffix, $blog_id ),
404            'subscriberManagementEnabled'     => (bool) $wp_admin_subscriber_management_enabled,
405            'overviewEnabled'                 => Feature_Flags::is_enabled( self::OVERVIEW_FEATURE_FLAG ),
406            'isSubscriptionSiteEditSupported' => $is_block_theme,
407            'setupPaymentPlansUrl'            => $setup_payment_plan_url,
408            'isSitePublic'                    => ! $status->is_private_site() && ! $status->is_coming_soon(),
409            'tracksUserData'                  => Jetpack_Tracks_Client::get_connected_user_tracks_identity(),
410        );
411
412        return $data;
413    }
414
415    /**
416     * Load the admin scripts.
417     */
418    public function load_admin_scripts() {
419        // This callback is registered via `admin_enqueue_scripts` from `admin_init`,
420        // which itself fires on `load-{$page_suffix}` in `add_wp_admin_menu()` â€” so it
421        // only fires on the Newsletter admin page; no need to re-check the page here.
422        // The Tracks transport is required on both surfaces â€” `analytics.initialize`
423        // only queues events into `window._tkq`; without `jp-tracks` loaded, no
424        // pixel.gif requests fire and the queue grows forever.
425        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
426        wp_add_inline_script(
427            'jp-tracks',
428            sprintf(
429                'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %s;',
430                absint( Connection_Manager::get_site_id( true ) )
431            ),
432            'before'
433        );
434
435        if ( self::is_modernized() ) {
436            // The i18n loader is registered on every admin page by jetpack-assets but
437            // only enqueued when depended on; the esbuild bundles don't pull it in.
438            // Enqueue it so the wp-build dashboard's init module can download its JS
439            // translation catalogs.
440            if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
441                wp_enqueue_script( 'wp-jp-i18n-loader' );
442            }
443
444            // wp-build manages the rest of its enqueue pipeline. The legacy
445            // newsletter script and JetpackScriptData are intentionally skipped
446            // for the wp-build dashboard.
447            return;
448        }
449
450        // The legacy bundle imports `@wordpress/ui`, which reaches
451        // `@wordpress/theme` and so lists `wp-theme` in its generated asset file.
452        // Core does not register that handle, and `load_wp_build()` â€” the only
453        // other caller â€” runs on the modernized path alone. Without this,
454        // WordPress silently drops the script over the unregistered dependency
455        // and the page renders blank. Request only the handles this bundle needs:
456        // `wp-theme` (Core never registers it), `wp-private-apis` (so the polyfill
457        // can replace Core's incomplete allowlist on older WP) and `wp-rich-text`
458        // (`@wordpress/ui` also reaches `@wordpress/dataviews`, whose dataform
459        // controls unlock rich-text's `privateApis` at module scope; WP 6.9 exports
460        // none, which throws "Cannot unlock an undefined object"). We leave out
461        // `wp-notices` so the polyfill's force-replacement never touches it.
462        if ( class_exists( \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::class ) ) {
463            \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(
464                'jetpack-newsletter',
465                array( 'wp-theme', 'wp-private-apis', 'wp-rich-text' )
466            );
467        }
468
469        Assets::register_script(
470            'jetpack-newsletter',
471            '../build/newsletter.js',
472            __FILE__,
473            array(
474                'in_footer'    => true,
475                'textdomain'   => 'jetpack-newsletter',
476                'enqueue'      => true,
477                'dependencies' => array( 'jetpack-script-data' ),
478            )
479        );
480    }
481
482    /**
483     * Get the subscriber management URL based on site type and filter settings.
484     *
485     * - If jetpack_wp_admin_subscriber_management_enabled filter is true: wp-admin subscribers page
486     * - If filter is false AND wpcom site: wordpress.com/subscribers/$domain
487     * - If filter is false AND Jetpack site: jetpack.com redirect URL
488     *
489     * @param bool   $wp_admin_enabled Whether wp-admin subscriber management is enabled.
490     * @param bool   $is_wpcom         Whether this is a WordPress.com site.
491     * @param string $site_suffix      The Calypso site suffix (home host, slashes as `::`).
492     * @param int    $blog_id          The blog ID.
493     * @return string The subscriber management URL.
494     */
495    private function get_subscriber_management_url( $wp_admin_enabled, $is_wpcom, $site_suffix, $blog_id ) {
496        // If wp-admin subscriber management is enabled, use the wp-admin page.
497        if ( $wp_admin_enabled ) {
498            return admin_url( 'admin.php?page=subscribers' );
499        }
500
501        // For wpcom sites, use the wordpress.com URL.
502        if ( $is_wpcom ) {
503            return 'https://wordpress.com/subscribers/' . $site_suffix;
504        }
505
506        // For Jetpack sites, use the jetpack.com redirect URL.
507        $site_id = $blog_id ? (int) $blog_id : Connection_Manager::get_site_id( true );
508        $args    = ( ! empty( $site_id ) )
509            ? array( 'site' => $site_id )
510            : array();
511
512        return Redirect::get_url(
513            'jetpack-settings-jetpack-manage-subscribers',
514            $args
515        );
516    }
517
518    /**
519     * Render the newsletter settings page.
520     */
521    public function render() {
522        ?>
523        <div id="newsletter-settings-root"></div>
524        <?php
525    }
526
527    /**
528     * Register a notice on the Reading settings page to clarify that the RSS
529     * excerpt setting does not control newsletter emails.
530     *
531     * @since 0.5.1
532     */
533    public function add_reading_page_notice() {
534        add_settings_field(
535            'jetpack_newsletter_reading_notice',
536            '',
537            array( $this, 'render_reading_page_notice' ),
538            'reading',
539            'default'
540        );
541    }
542
543    /**
544     * Render the clarifying notice on the Reading settings page.
545     *
546     * Uses JavaScript to relocate the notice next to the "For each post in a feed"
547     * (rss_use_excerpt) setting.
548     *
549     * @since 0.5.1
550     */
551    public function render_reading_page_notice() {
552        $newsletter_url = Urls::get_newsletter_settings_url();
553
554        printf(
555            '<p class="description" id="jetpack-newsletter-reading-notice">%s</p>',
556            sprintf(
557                wp_kses(
558                    /* translators: %s is a link to the Newsletter settings page. */
559                    __( 'To control what’s included in newsletter emails, visit your <a href="%s">Newsletter settings</a>.', 'jetpack-newsletter' ),
560                    array(
561                        'a' => array(
562                            'href' => array(),
563                        ),
564                    )
565                ),
566                esc_url( $newsletter_url )
567            )
568        );
569        ?>
570        <script type="text/javascript">
571            document.addEventListener( 'DOMContentLoaded', function() {
572                var notice = document.getElementById( 'jetpack-newsletter-reading-notice' );
573                var excerptInput = document.querySelector( 'input[name="rss_use_excerpt"]' );
574                var excerptRow = excerptInput ? excerptInput.closest( 'tr' ) : null;
575
576                if ( ! notice || ! excerptRow ) {
577                    return;
578                }
579
580                // Remember the original parent before moving the notice.
581                var originalTable = notice.closest( 'table' );
582                var excerptTable = excerptRow.closest( 'table' );
583
584                // Move the notice into the rss_use_excerpt row's fieldset.
585                excerptRow.querySelector( 'td' ).appendChild( notice );
586
587                // Remove the now-empty original table (if it's different from the excerpt's table).
588                if ( originalTable && originalTable !== excerptTable ) {
589                    originalTable.remove();
590                }
591            } );
592        </script>
593        <?php
594    }
595
596    /**
597     * Load the wp-build entry file and register its polyfills.
598     *
599     * Only called on `?page=jetpack-newsletter` admin requests when the
600     * modernization filter is enabled. Keeps wp-build off every other request.
601     *
602     * @return void
603     */
604    private static function load_wp_build() {
605        $build_index = dirname( __DIR__ ) . '/build/build.php';
606
607        if ( ! file_exists( $build_index ) ) {
608            return;
609        }
610
611        require_once $build_index;
612
613        // wp-build registers standalone modules (e.g. the init module) on
614        // wp_default_scripts, which has already fired by admin_menu. Register them
615        // directly so the init module makes it into the import map.
616        if ( function_exists( 'jetpack_newsletter_register_script_modules' ) ) {
617            jetpack_newsletter_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
618        }
619
620        \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(
621            'jetpack-newsletter',
622            array_merge(
623                \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES,
624                \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS
625            )
626        );
627    }
628
629    /**
630     * Load wp-build with the screen ID aliased across its generated enqueue check.
631     *
632     * @see WP_Build_Screen_Id::load_with_alias()
633     * @return void
634     */
635    private static function load_wp_build_with_screen_alias() {
636        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
637        if ( method_exists( \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
638            \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id::load_with_alias(
639                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
640                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
641                function () {
642                    self::load_wp_build();
643                }
644            );
645            return;
646        }
647
648        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
649        self::load_wp_build();
650        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
651    }
652
653    /**
654     * Alias the current screen ID to satisfy wp-build's auto-generated enqueue check.
655     *
656     * Wp-build's `<page>-wp-admin` enqueue callback enqueues only when the screen ID
657     * matches the wp-build page slug (`jetpack-newsletter-dashboard`). Our wp-admin
658     * menu slug stays `jetpack-newsletter`, so we mutate the screen object in place
659     * to make the check pass without changing the user-facing URL.
660     *
661     * Hooked only when modernization is on AND we're on the Newsletter admin page,
662     * so this never affects any other request.
663     *
664     * @since 0.16.0 Takes no argument; hooked on `admin_enqueue_scripts`.
665     *
666     * @return void
667     */
668    public static function alias_screen_id_for_wp_build() {
669        $screen = get_current_screen();
670        if ( ! $screen ) {
671            return;
672        }
673
674        self::$wp_build_original_screen_id = $screen->id;
675        $screen->id                        = 'jetpack-newsletter-dashboard';
676    }
677
678    /**
679     * Undo alias_screen_id_for_wp_build(), so code after the generated check sees the real screen ID.
680     *
681     * @since 0.16.0
682     *
683     * @return void
684     */
685    public static function restore_screen_id_after_wp_build() {
686        $screen = get_current_screen();
687        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
688            return;
689        }
690
691        $screen->id                        = self::$wp_build_original_screen_id;
692        self::$wp_build_original_screen_id = null;
693    }
694
695    /**
696     * Returns true when the wp-build modernization filter is enabled.
697     *
698     * The modernized Newsletter dashboard, wp-admin subscriber management, and the
699     * retired Calypso Subscribers submenu now default on for every site. Hosts (and
700     * a11ns who want the legacy view back) can still force the legacy experience with
701     * `add_filter( self::MODERNIZATION_FILTER, '__return_false' );`.
702     *
703     * @return bool
704     */
705    private static function is_modernized() {
706        return (bool) apply_filters( self::MODERNIZATION_FILTER, true );
707    }
708
709    /**
710     * Returns true when subscribers are managed in wp-admin rather than on WordPress.com or Jetpack Cloud.
711     *
712     * @return bool
713     */
714    private static function is_wp_admin_subscriber_management_enabled() {
715        /** This filter is documented in projects/plugins/jetpack/modules/subscriptions.php */
716        return (bool) apply_filters( 'jetpack_wp_admin_subscriber_management_enabled', true );
717    }
718
719    /**
720     * Publish the Subscribers tab URL so other dashboards can link to it.
721     *
722     * @since 0.17.0
723     *
724     * @param array $data The existing script data.
725     * @return array The script data, with `newsletter.subscribersUrl` set to null when the current user cannot open the tab.
726     */
727    public static function add_subscribers_url_script_data( $data ) {
728        $data['newsletter']['subscribersUrl'] = self::is_subscribers_tab_available() ? Urls::get_subscribers_url() : null;
729
730        return $data;
731    }
732
733    /**
734     * Whether the current user can open the Subscribers tab of the Newsletter page.
735     *
736     * Reads the admin menu, so it returns false until `admin_menu` has run.
737     *
738     * @return bool
739     */
740    private static function is_subscribers_tab_available() {
741        // The legacy page and a host that manages subscribers elsewhere both leave the page Settings-only.
742        if ( ! self::is_modernized() || ! self::is_wp_admin_subscriber_management_enabled() ) {
743            return false;
744        }
745
746        // Registration applies the page's own gates: a connected site, the subscriptions module, and `manage_options`.
747        return function_exists( 'menu_page_url' ) && '' !== menu_page_url( self::ADMIN_PAGE_SLUG, false );
748    }
749
750    /**
751     * Returns true when the current request targets the Newsletter admin page.
752     *
753     * Used to scope wp-build loading to the one page that needs it. The
754     * `$_GET['page']` value is populated by wp-admin/admin.php before any of
755     * our hooks fire, so this check is reliable from `init_hooks()` onwards.
756     *
757     * @return bool
758     */
759    private static function is_newsletter_admin_request() {
760        if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
761            return false;
762        }
763
764        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::ADMIN_PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
765    }
766}