Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.99% covered (warning)
84.99%
691 / 813
72.37% covered (warning)
72.37%
55 / 76
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
84.99% covered (warning)
84.99%
691 / 813
72.37% covered (warning)
72.37%
55 / 76
580.12
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
7
 use_local_module_options
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 restore_module_options
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 is_licensing_ui_enabled
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 add_my_jetpack_menu_item
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 current_user_can_access_page
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 admin_init
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 is_onboarding_settled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_redirect_to_onboarding
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 get_onboarding_redirect_args_for_step
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 is_onboarding_available
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_partner_coupon_screen
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 get_onboarding_redirect_args
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
12
 silence_onboarding_notices
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 hide_onboarding_jitms
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 add_onboarding_admin_body_class
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 can_use_analytics
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_onboarding_wizard_enabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 has_never_registered_jetpack
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_onboarding_wizard_state
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 is_my_jetpack_admin_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_onboarding_request
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 is_onboarding_takeover
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 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
 should_load_wp_build
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 should_render_wp_build
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 maybe_load_wp_build
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 load_wp_build
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 add_admin_shell_member
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 load_as_admin_shell_guest
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 require_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 enqueue_scripts
96.97% covered (success)
96.97%
128 / 132
0.00% covered (danger)
0.00%
0 / 1
12
 get_header_state
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add_script_data
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 is_modules_rest_api_enabled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_modules_management_url
51.43% covered (warning)
51.43%
36 / 70
0.00% covered (danger)
0.00%
0 / 1
244.13
 print_modules_fallback
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
10
 get_products_section
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 add_admin_script_data
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 get_registered_admin_page_slugs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 is_admin_page_available
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_assets_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_installed_jetpack_plugins
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_active_modules
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 is_jetpack_user_new
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 register_feature_flags
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
1
 is_header_controls_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 maybe_enqueue_header_controls_script
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 get_my_jetpack_flags
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 is_overview_v1_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 host_shows_my_jetpack
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_settings_consolidation_on_for_site
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_settings_consolidation_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 register_settings_panel_urls
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 resolve_settings_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 is_modules_to_features_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_seo_opt_in_state
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 admin_page
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 register_rest_endpoints
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
3
 permissions_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 should_initialize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 is_offline_features_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setup_historically_active_jetpack_modules_sync
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 get_site
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_site_info
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
5.16
 is_commercial_site
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 is_registered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dismiss_welcome_banner
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 has_file_system_write_access
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
8.12
 get_idc_container_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_show_red_bubble
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
12
 has_connection_error
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 get_alerts_counted_elsewhere
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 enqueue_red_bubble_script
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 get_recommended_modules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * WP Admin page with information and configuration shared among all Jetpack stand-alone plugins
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Admin_UI\Admin_Shell;
12use Automattic\Jetpack\Assets;
13use Automattic\Jetpack\Boost_Speed_Score\Speed_Score;
14use Automattic\Jetpack\Boost_Speed_Score\Speed_Score_History;
15use Automattic\Jetpack\Connection\Client;
16use Automattic\Jetpack\Connection\Error_Handler as Connection_Error_Handler;
17use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
18use Automattic\Jetpack\Connection\Manager as Connection_Manager;
19use Automattic\Jetpack\Connection\Rest_Authentication as Connection_Rest_Authentication;
20use Automattic\Jetpack\Connection\REST_Jetpack_AI_JWT;
21use Automattic\Jetpack\Constants as Jetpack_Constants;
22use Automattic\Jetpack\ExPlat;
23use Automattic\Jetpack\Feature_Flags\Feature_Flags;
24use Automattic\Jetpack\JITMS\JITM;
25use Automattic\Jetpack\Licensing;
26use Automattic\Jetpack\Menu_Badges\Menu_Badges;
27use Automattic\Jetpack\Menu_Badges\Notification_Counts;
28use Automattic\Jetpack\Modules;
29use Automattic\Jetpack\Partner_Coupon;
30use Automattic\Jetpack\Plugins_Installer;
31use Automattic\Jetpack\Status;
32use Automattic\Jetpack\Status\Host as Status_Host;
33use Automattic\Jetpack\Sync\Functions as Sync_Functions;
34use Automattic\Jetpack\Terms_Of_Service;
35use Automattic\Jetpack\Tracking;
36use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
37use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
38use Jetpack;
39use WP_Error;
40
41/**
42 * The main Initializer class that registers the admin menu and eneuque the assets.
43 */
44class Initializer {
45
46    /**
47     * My Jetpack package version
48     *
49     * @var string
50     */
51    const PACKAGE_VERSION = '6.17.1';
52
53    /**
54     * Feature flag that swaps the single-screen onboarding takeover for the setup wizard.
55     */
56    const ONBOARDING_WIZARD_FEATURE_FLAG = 'my-jetpack-onboarding-wizard';
57
58    /**
59     * User option recording that this user chose to skip setup.
60     *
61     * Per user rather than per site, because skipping is one person saying "not
62     * now": a second admin arriving at a site nobody has set up should still be
63     * offered the flow. Finishing it is the site-wide fact, and that is
64     * ONBOARDING_COMPLETED_OPTION.
65     *
66     * A user option rather than plain user meta: `wp_usermeta` is network-wide, so
67     * without the blog prefix a skip on one site would silence setup on all of them.
68     */
69    const ONBOARDING_DISMISSED_USER_OPTION = 'jetpack_my_jetpack_onboarding_dismissed';
70
71    /**
72     * Option recording that someone has finished setup on this site.
73     *
74     * Site-wide, because the flow connects the site and switches modules on, so a
75     * second admin does not need it again. Deliberately not autoloaded.
76     */
77    const ONBOARDING_COMPLETED_OPTION = 'jetpack_my_jetpack_onboarding_completed';
78
79    /**
80     * Handle for the classic script that carries the React initial state.
81     *
82     * Plugins that render My Jetpack components on their own pages (Boost) depend on this name.
83     */
84    const DATA_SCRIPT_HANDLE = 'my_jetpack_main_app';
85
86    /**
87     * Handle for the webpack bundle that renders the onboarding takeover.
88     */
89    const ONBOARDING_SCRIPT_HANDLE = 'my_jetpack_onboarding';
90
91    /**
92     * Handle for the webpack bundle that gives dashboards outside My Jetpack the shared header's controls.
93     */
94    const HEADER_CONTROLS_SCRIPT_HANDLE = 'my-jetpack-header-controls';
95
96    /**
97     * The wp-build page ID, which the generated enqueue check expects as the screen ID.
98     */
99    const WP_BUILD_PAGE_ID = 'my-jetpack-dashboard';
100
101    /**
102     * HTML container ID for the IDC screen on My Jetpack page.
103     */
104    private const IDC_CONTAINER_ID = 'my-jetpack-identity-crisis-container';
105
106    public const JETPACK_PLUGIN_SLUGS = array(
107        'jetpack-backup',
108        'jetpack-boost',
109        'zerobscrm',
110        'jetpack',
111        'jetpack-protect',
112        'jetpack-social',
113        'jetpack-videopress',
114        'jetpack-search',
115        'jetpack-stats',
116    );
117
118    private const MY_JETPACK_SITE_INFO_TRANSIENT_KEY = 'my-jetpack-site-info';
119
120    /**
121     * Feature flag for connecting the WordPress.com account from a Features card that needs one.
122     *
123     * @var string
124     */
125    public const ACCOUNT_CONNECT_FLAG = 'my-jetpack-account-connect';
126
127    /**
128     * Feature flag for the shared header's global controls.
129     *
130     * @var string
131     */
132    public const HEADER_CONTROLS_FLAG = 'my-jetpack-header-controls';
133
134    /**
135     * Feature flag for Overview v1.
136     *
137     * @var string
138     */
139    public const OVERVIEW_V1_FLAG = 'my-jetpack-overview-v1';
140
141    /**
142     * Feature flag for sending classic Modules links to Features.
143     *
144     * @var string
145     */
146    public const MODULES_TO_FEATURES_FLAG = 'my-jetpack-modules-to-features';
147
148    /**
149     * Feature flag for consolidating settings in My Jetpack instead of the Settings screen.
150     *
151     * @var string
152     */
153    public const SETTINGS_CONSOLIDATION_FLAG = 'my-jetpack-settings-consolidation';
154
155    /**
156     * Modules whose Jetpack settings link opens in the Features panel. A subset of `moduleSettings` in `settings-panel/registry.ts`; see there for which modules stay out.
157     */
158    public const SETTINGS_PANEL_MODULES = array( 'carousel', 'infinite-scroll', 'post-by-email', 'related-posts' );
159
160    /**
161     * Holds info/data about the site (from the /sites/%d endpoint)
162     *
163     * @var object
164     */
165    public static $site_info;
166
167    /**
168     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
169     *
170     * @var string|null
171     */
172    private static $wp_build_original_screen_id = null;
173
174    /**
175     * Initialize My Jetpack
176     *
177     * @return void
178     */
179    public static function init() {
180        self::register_feature_flags();
181
182        // Before the gate: the Jetpack plugin renders this package's connection screen and footer
183        // links even where My Jetpack is off, and `myJetpackInitialState` only exists on its own page.
184        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_admin_script_data' ) );
185        add_action( 'admin_head', array( __CLASS__, 'print_modules_fallback' ) );
186
187        if ( ! self::should_initialize() || did_action( 'my_jetpack_init' ) ) {
188            return;
189        }
190
191        if ( self::is_offline_features_enabled() ) {
192            add_filter( 'jetpack_my_jetpack_should_enable_add_license_screen', '__return_false' );
193            Connection_Rest_Authentication::init();
194            add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'use_local_module_options' ), 10, 3 );
195            add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'restore_module_options' ), 10, 3 );
196            add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
197            add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
198            add_action( 'admin_menu', array( __CLASS__, 'add_my_jetpack_menu_item' ) );
199            do_action( 'my_jetpack_init' );
200            return;
201        }
202
203        // Answer "is this product on?" for admin menu registration.
204        Menu_Visibility::init();
205
206        self::register_settings_panel_urls();
207
208        // Extend jetpack plugins action links.
209        Products::extend_plugins_action_links();
210
211        // Set up the REST authentication hooks.
212        Connection_Rest_Authentication::init();
213
214        if ( self::is_licensing_ui_enabled() ) {
215            Licensing::instance()->initialize();
216        }
217
218        // Initialize Boost Speed Score
219        new Speed_Score( array(), 'jetpack-my-jetpack' );
220
221        // Add custom WP REST API endoints.
222        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
223
224        // Both of wp-build's deadlines fall later in this request: its enqueue check must be
225        // hooked before `admin_enqueue_scripts`, and its render function defined before
226        // the page callback.
227        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
228
229        add_action( 'admin_menu', array( __CLASS__, 'add_my_jetpack_menu_item' ) );
230
231        if ( class_exists( Admin_Shell::class ) ) {
232            add_filter( 'jetpack_admin_shell_members', array( __CLASS__, 'add_admin_shell_member' ) );
233            // Standalone plugins reach admin-ui only through the admin menu, which admin-ajax never builds.
234            if ( method_exists( Admin_Shell::class, 'init' ) ) {
235                Admin_Shell::init();
236            }
237        }
238
239        add_action( 'admin_init', array( __CLASS__, 'setup_historically_active_jetpack_modules_sync' ) );
240
241        // Not on admin_init: modules are usually toggled over REST, where admin_init never fires.
242        add_action( 'jetpack_activate_module', array( Historically_Active_Modules::class, 'expire_refresh' ) );
243        add_action( 'jetpack_deactivate_module', array( Historically_Active_Modules::class, 'expire_refresh' ) );
244
245        // Nothing else gets to talk over the takeover. See both methods for why it
246        // takes two hooks rather than one.
247        add_action( 'in_admin_header', array( __CLASS__, 'silence_onboarding_notices' ), PHP_INT_MAX );
248        add_filter( 'jetpack_display_jitms_on_screen', array( __CLASS__, 'hide_onboarding_jitms' ), 10, 2 );
249        // Registered on admin_menu (not admin_init) and well before priority 100000, so the
250        // counts it registers exist before the menu-badges renderer runs on admin_menu 100000.
251        add_action( 'admin_menu', array( __CLASS__, 'maybe_show_red_bubble' ), 30 );
252
253        // Set up the ExPlat package endpoints
254        ExPlat::init();
255
256        // Sets up JITMS.
257        JITM::configure();
258
259        // Track whether the user is an agency, for the Automattic for Agencies banner.
260        Jetpack_Manage::init();
261
262        /**
263         * Fires after the My Jetpack package is initialized
264         *
265         * @since 0.1.0
266         */
267        do_action( 'my_jetpack_init' );
268    }
269
270    /**
271     * Keep uncached module-list options local on the offline route.
272     *
273     * @since 6.10.0
274     * @param mixed            $response Response before the callback.
275     * @param array            $handler  Route handler.
276     * @param \WP_REST_Request $request  REST request.
277     * @return mixed
278     */
279    public static function use_local_module_options( $response, $handler, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- WordPress REST filter signature.
280        if ( '/jetpack/v4/module/all' === $request->get_route() ) {
281            // Keep module activation editable when the standalone plugin is not running;
282            // a stale autoloaded VideoPress class can still attach its forcing filter.
283            if ( ! Jetpack_Constants::is_defined( 'JETPACK_VIDEOPRESS_ROOT_FILE' )
284                && ! Products\Videopress::is_standalone_plugin_active()
285                && remove_filter( 'jetpack_active_modules', array( \Automattic\Jetpack\VideoPress\Module_Control::class, 'add_videopress_to_array' ) )
286                && class_exists( 'Jetpack_Modules_Overrides', false )
287            ) {
288                \Jetpack_Modules_Overrides::instance()->clear_cache();
289            }
290            add_filter( 'default_option_monitor_receive_notifications', '__return_false' );
291            add_filter( 'default_option_post_by_email_address' . get_current_user_id(), '__return_false' );
292        }
293        return $response;
294    }
295
296    /**
297     * Restore normal option reads after the offline module-list callback.
298     *
299     * @since 6.10.0
300     * @param mixed            $response Response after the callback.
301     * @param array            $handler  Route handler.
302     * @param \WP_REST_Request $request  REST request.
303     * @return mixed
304     */
305    public static function restore_module_options( $response, $handler, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- WordPress REST filter signature.
306        if ( '/jetpack/v4/module/all' === $request->get_route() ) {
307            remove_filter( 'default_option_monitor_receive_notifications', '__return_false' );
308            remove_filter( 'default_option_post_by_email_address' . get_current_user_id(), '__return_false' );
309        }
310        return $response;
311    }
312
313    /**
314     * Acts as a feature flag, returning a boolean for whether we should show the licensing UI.
315     *
316     * @since 1.2.0
317     *
318     * @return boolean
319     */
320    public static function is_licensing_ui_enabled() {
321        // Default changed to true in 1.5.0.
322        $is_enabled = true;
323
324        /*
325         * Bail if My Jetpack is not enabled,
326         * and thus the licensing UI shouldn't be enabled either.
327         */
328        if ( ! self::should_initialize() ) {
329            $is_enabled = false;
330        }
331
332        /**
333         * Acts as a feature flag, returning a boolean for whether we should show the licensing UI.
334         *
335         * @param bool $is_enabled Defaults to true.
336         *
337         * @since 1.2.0
338         * @since 1.5.0 Update default value to true.
339         */
340        return apply_filters(
341            'jetpack_my_jetpack_should_enable_add_license_screen',
342            $is_enabled
343        );
344    }
345
346    /**
347     * Add My Jetpack menu item to the admin menu.
348     *
349     * @return void
350     */
351    public static function add_my_jetpack_menu_item() {
352        if ( self::is_offline_features_enabled() && ! self::current_user_can_access_page() ) {
353            return;
354        }
355
356        $position    = defined( Admin_Menu::class . '::POSITION_FIRST' ) ? Admin_Menu::POSITION_FIRST : -10;
357        $menu_slug   = 'my-jetpack';
358        $page_suffix = Admin_Menu::add_menu(
359            __( 'My Jetpack', 'jetpack-my-jetpack' ),
360            __( 'My Jetpack', 'jetpack-my-jetpack' ),
361            'edit_posts',
362            $menu_slug,
363            array( __CLASS__, 'admin_page' ),
364            $position
365        );
366        add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
367        // Users who can edit posts but have no Jetpack menu get an admin_page_ hook instead.
368        add_action( 'load-admin_page_' . $menu_slug, array( __CLASS__, 'admin_init' ) );
369    }
370
371    /**
372     * Whether the current user may access the page selected by My Jetpack.
373     *
374     * @since 6.10.0
375     * @return bool
376     */
377    public static function current_user_can_access_page() {
378        return current_user_can( 'edit_posts' )
379            && ( ! self::is_offline_features_enabled() || REST_Main_Features::permissions_callback() );
380    }
381
382    /**
383     * Callback for the load my jetpack page hook.
384     *
385     * @return void
386     */
387    public static function admin_init() {
388        if ( self::is_offline_features_enabled() ) {
389            if ( ! self::current_user_can_access_page() ) {
390                wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'jetpack-my-jetpack' ), 403 );
391            }
392            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
393            return;
394        }
395
396        self::maybe_redirect_to_onboarding();
397
398        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No nonce needed for redirect flow control
399        $step = isset( $_GET['step'] ) ? sanitize_text_field( wp_unslash( $_GET['step'] ) ) : '';
400
401        // If the user reaches the onboarding page, add a class to the body
402        if ( $step === 'onboarding' ) {
403            add_filter( 'admin_body_class', array( __CLASS__, 'add_onboarding_admin_body_class' ) );
404        }
405
406        self::$site_info = self::get_site_info();
407        add_filter( 'identity_crisis_container_id', array( static::class, 'get_idc_container_id' ) );
408        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
409    }
410
411    /**
412     * Whether this user has already settled setup, one way or the other.
413     *
414     * Two separate facts. Finishing is site-wide: the wizard connects the site and
415     * switches modules on, so once it is done nobody needs the takeover again.
416     * Skipping is this person saying "not now", and it must not answer for the next
417     * admin who arrives at a site nobody has set up.
418     *
419     * @internal Not part of the package's public API.
420     *
421     * @return bool
422     */
423    public static function is_onboarding_settled() {
424        if ( get_option( self::ONBOARDING_COMPLETED_OPTION, false ) ) {
425            return true;
426        }
427
428        return (bool) get_user_option( self::ONBOARDING_DISMISSED_USER_OPTION );
429    }
430
431    /**
432     * Send the user into setup when they land on My Jetpack without one.
433     *
434     * Runs from My Jetpack's own page load, which nothing but a request for that page
435     * reaches and which fires before any output, so it needs no guards of its own.
436     *
437     * @return void
438     */
439    public static function maybe_redirect_to_onboarding() {
440        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No nonce needed for redirect flow control
441        $step          = isset( $_GET['step'] ) ? sanitize_text_field( wp_unslash( $_GET['step'] ) ) : '';
442        $redirect_args = self::get_onboarding_redirect_args_for_step( $step );
443
444        if ( null === $redirect_args ) {
445            return;
446        }
447
448        $location = wp_sanitize_redirect( add_query_arg( $redirect_args, admin_url( 'admin.php' ) ) );
449
450        // Remove wp_get_referer filter applied in `fix_redirect` method of `Jetpack_Admin` class
451        remove_filter( 'wp_redirect', 'wp_get_referer' );
452        wp_safe_redirect( $location );
453
454        exit( 0 );
455    }
456
457    /**
458     * Get the setup redirect for the current user and site at the requested step.
459     *
460     * @param string $step The requested setup step, or empty for the dashboard.
461     * @return array|null Query args for the redirect, or null to stay on the page.
462     */
463    private static function get_onboarding_redirect_args_for_step( $step ) {
464        $wizard_enabled = self::is_onboarding_wizard_enabled();
465        $connection     = new Connection_Manager();
466
467        // Only the wizard connects a user, so only it may count a site-only connection as
468        // unfinished setup. The single screen can only register the site, so asking it for
469        // an owner leaves that site on a screen with no way back out.
470        $connection_done = $wizard_enabled ? $connection->has_connected_owner() : $connection->is_connected();
471
472        // Onboarding's only action is the connect request, which answers a user
473        // without `jetpack_connect` with a 403.
474        return self::get_onboarding_redirect_args(
475            $step,
476            $connection_done,
477            self::is_onboarding_available() && current_user_can( 'jetpack_connect' ),
478            $wizard_enabled,
479            // Only the wizard records a settled run, and only a registered site may act on one:
480            // My Jetpack requires the site connection that accepting the terms creates.
481            $wizard_enabled && $connection->is_connected() && self::is_onboarding_settled()
482        );
483    }
484
485    /**
486     * Whether the My Jetpack onboarding flow is available on this site.
487     *
488     * WordPress.com Simple sites are connected by definition and don't manage their
489     * connection through My Jetpack, so the onboarding flow (which asks the user to
490     * connect) never applies there. A pending partner coupon brings its own connect screen.
491     *
492     * @internal Not part of the package's public API.
493     *
494     * @return bool
495     */
496    public static function is_onboarding_available() {
497        if ( self::is_offline_features_enabled() ) {
498            return false;
499        }
500        return ! ( new Status_Host() )->is_wpcom_simple() && null === self::get_partner_coupon_screen();
501    }
502
503    /**
504     * The partner coupon screen's data, when it should replace the dashboard.
505     *
506     * Coupons exist only with the Jetpack plugin, which supplies their products and images.
507     *
508     * @since 6.5.0
509     *
510     * @return array{coupon: array, assetBaseUrl: string}|null
511     */
512    public static function get_partner_coupon_screen() {
513        if (
514            self::is_offline_features_enabled()
515            || ! self::should_initialize()
516            || ! current_user_can( 'manage_options' )
517            || ! Jetpack_Constants::is_defined( 'JETPACK__PLUGIN_FILE' )
518        ) {
519            return null;
520        }
521
522        $coupon = Partner_Coupon::get_coupon();
523        if ( ! $coupon ) {
524            return null;
525        }
526
527        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks which screen renders.
528        if ( ! isset( $_GET['showCouponRedemption'] ) && ( new Connection_Manager() )->has_connected_owner() ) {
529            return null;
530        }
531
532        return array(
533            'coupon'       => $coupon,
534            'assetBaseUrl' => plugins_url( '', Jetpack_Constants::get_constant( 'JETPACK__PLUGIN_FILE' ) ),
535        );
536    }
537
538    /**
539     * Decide whether the current My Jetpack request should redirect, and where to.
540     *
541     * @internal Not part of the package's public API.
542     *
543     * @param string $step                 The current `step` query param.
544     * @param bool   $connection_done      Whether the connection the takeover asks for is in place:
545     *                                     a connected owner with the wizard, a connected site without it.
546     * @param bool   $onboarding_available Whether the onboarding flow is available on this site.
547     * @param bool   $wizard_enabled       Whether the takeover renders the setup wizard.
548     * @param bool   $is_settled           Whether this user has already finished or skipped setup.
549     * @return array|null Query args for the redirect, or null to stay on the current page.
550     */
551    public static function get_onboarding_redirect_args( $step, $connection_done, $onboarding_available, $wizard_enabled = false, $is_settled = false ) {
552        /*
553         * Only the automatic redirect is suppressed once setup is settled. Asking for
554         * the onboarding page by its own URL still works, so someone who skipped can
555         * go back to it, and so can a second admin the site-wide flag does not cover.
556         */
557        if ( $onboarding_available && ! $connection_done && ! $is_settled && $step !== 'onboarding' ) {
558            // The connection setup asks for is not in place, so setup is unfinished.
559            return array(
560                'page' => 'my-jetpack',
561                'step' => 'onboarding',
562            );
563        }
564
565        // The wizard connects mid-flow: the user authorizes on WordPress.com and is sent
566        // back here to finish setup, so arriving connected is not the same as finishing.
567        if ( $step === 'onboarding' && $connection_done && $onboarding_available && $wizard_enabled ) {
568            return null;
569        }
570
571        if ( $step === 'onboarding' && ( ! $onboarding_available || $connection_done ) ) {
572            // Nothing left to set up here, or onboarding does not apply on this site.
573            return array( 'page' => 'my-jetpack' );
574        }
575
576        return null;
577    }
578
579    /**
580     * Empty the notice channels while the takeover is on screen.
581     *
582     * The takeover hides `.notice`, `.error` and `.updated` in CSS, which only
583     * catches notices that use WordPress's own classes. Plenty do not, and a plugin
584     * announcing itself in its own markup lands on top of a full-screen flow that
585     * has no way to scroll past it.
586     *
587     * On `in_admin_header`, the last hook before `admin-header.php` prints notices,
588     * so a notice registered late is caught too.
589     *
590     * @return void
591     */
592    public static function silence_onboarding_notices() {
593        if ( ! self::is_onboarding_request() ) {
594            return;
595        }
596
597        remove_all_actions( 'admin_notices' );
598        remove_all_actions( 'all_admin_notices' );
599        remove_all_actions( 'network_admin_notices' );
600        remove_all_actions( 'user_admin_notices' );
601    }
602
603    /**
604     * Keep Just In Time Messages off the takeover.
605     *
606     * Emptying the notice channels above would also drop the JITM, since that is how
607     * it renders, but only after it has enqueued its script and stylesheet. This is
608     * the package's own opt-out and it runs before any of that.
609     *
610     * The screen id cannot answer this on its own: the takeover and the My Jetpack
611     * dashboard are the same screen, and only the query arg separates them.
612     *
613     * @param bool   $show      Whether to show JITMs on this screen.
614     * @param string $screen_id The current screen's id.
615     * @return bool
616     */
617    public static function hide_onboarding_jitms( $show, $screen_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required by the filter signature.
618        return self::is_onboarding_request() ? false : $show;
619    }
620
621    /**
622     * Add a body class to the My Jetpack onboarding page.
623     * This class hides the WP Admin toolbar and the sidebar menu.
624     *
625     * @param string $classes The body classes.
626     * @return string The modified body classes.
627     */
628    public static function add_onboarding_admin_body_class( $classes ) {
629        $classes .= ' jetpack-admin-full-screen';
630        return $classes;
631    }
632
633    /**
634     * Returns whether we are in condition to track to use
635     * Analytics functionality like Tracks, MC, or GA.
636     */
637    public static function can_use_analytics() {
638        $status     = new Status();
639        $connection = new Connection_Manager();
640        $tracking   = new Tracking( 'jetpack', $connection );
641
642        return $tracking->should_enable_tracking( new Terms_Of_Service(), $status );
643    }
644
645    /**
646     * Whether the onboarding takeover renders the setup wizard.
647     *
648     * Self-hosted only. On WordPress.com the platform owns the connection and nobody chose
649     * to install Jetpack, so a screen inviting them to start with it and connect an account
650     * does not apply. `is_wpcom_platform()` covers Simple and WordPress.com on Atomic, and
651     * requires wpcomsh, so other Atomic-hosted products still count as self-hosted here.
652     *
653     * @since 6.11.0
654     *
655     * @return bool
656     */
657    public static function is_onboarding_wizard_enabled() {
658        if ( ( new Status_Host() )->is_wpcom_platform() ) {
659            return false;
660        }
661
662        if ( ! Feature_Flags::is_enabled( self::ONBOARDING_WIZARD_FEATURE_FLAG ) ) {
663            return false;
664        }
665
666        // The wizard registers the site at its own first step, which writes the marker
667        // below, so asking again on the way back from WordPress.com would drop someone
668        // out of a run already under way.
669        if ( ( new Connection_Manager() )->is_connected() ) {
670            return true;
671        }
672
673        return self::has_never_registered_jetpack();
674    }
675
676    /**
677     * Whether the Jetpack plugin has never registered this site.
678     *
679     * `unique_registrations` is written on `jetpack_site_registered` by the Jetpack plugin
680     * alone, so a site that has only ever run a standalone plugin still reads as new here.
681     *
682     * @internal Not part of the package's public API.
683     *
684     * @since 6.11.0
685     *
686     * @return bool
687     */
688    public static function has_never_registered_jetpack() {
689        return ! \Jetpack_Options::get_option( 'unique_registrations' );
690    }
691
692    /**
693     * The wizard's configuration, or null while the takeover stays on its single screen.
694     *
695     * @since 6.11.0
696     *
697     * @return array{exitUrl: string, dashboardUrl: string}|null
698     */
699    public static function get_onboarding_wizard_state() {
700        if ( ! self::is_onboarding_wizard_enabled() ) {
701            return null;
702        }
703
704        return array(
705            // Where skipping the wizard lands: the admin menu is hidden, so leaving is always one click.
706            'exitUrl'      => admin_url( 'admin.php?page=my-jetpack' ),
707            // Where leaving Jetpack altogether lands: wp-admin's own dashboard.
708            'dashboardUrl' => admin_url(),
709        );
710    }
711
712    /**
713     * Whether the current request targets the My Jetpack admin page.
714     *
715     * @since 6.3.0
716     *
717     * @return bool
718     */
719    public static function is_my_jetpack_admin_request() {
720        if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
721            return false;
722        }
723
724        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === 'my-jetpack'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
725    }
726
727    /**
728     * Whether the current request is the full-viewport onboarding takeover.
729     *
730     * Onboarding hides all wp-admin chrome and never renders through wp-build. The page
731     * is part of the question: `in_admin_header` and the JITM filter run everywhere.
732     *
733     * @since 6.3.0
734     *
735     * @return bool
736     */
737    public static function is_onboarding_request() {
738        if ( ! self::is_my_jetpack_admin_request() ) {
739            return false;
740        }
741
742        if ( ! isset( $_GET['step'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
743            return false;
744        }
745
746        return sanitize_text_field( wp_unslash( $_GET['step'] ) ) === 'onboarding'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
747    }
748
749    /**
750     * Whether this request is the onboarding takeover rather than the dashboard.
751     *
752     * Loading wp-build, enqueueing scripts and rendering the page must all agree,
753     * so they share this one expression.
754     *
755     * @since 6.3.0
756     *
757     * @return bool
758     */
759    public static function is_onboarding_takeover() {
760        return self::is_onboarding_request() && self::is_onboarding_available();
761    }
762
763    /**
764     * Alias the screen ID to satisfy wp-build's generated enqueue check.
765     *
766     * @since 6.3.0
767     *
768     * @return void
769     */
770    public static function alias_screen_id_for_wp_build() {
771        $screen = get_current_screen();
772
773        if ( ! $screen ) {
774            return;
775        }
776
777        self::$wp_build_original_screen_id = $screen->id;
778        $screen->id                        = self::WP_BUILD_PAGE_ID;
779    }
780
781    /**
782     * Undo alias_screen_id_for_wp_build(), since JITM builds its message path from the screen ID.
783     *
784     * @since 6.3.0
785     *
786     * @return void
787     */
788    public static function restore_screen_id_after_wp_build() {
789        $screen = get_current_screen();
790
791        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
792            return;
793        }
794
795        $screen->id                        = self::$wp_build_original_screen_id;
796        self::$wp_build_original_screen_id = null;
797    }
798
799    /**
800     * Whether this request should load wp-build at all.
801     *
802     * Also what keeps WP_Build_Polyfills from replacing core scripts on every other admin page.
803     *
804     * @since 6.3.0
805     *
806     * @return bool
807     */
808    public static function should_load_wp_build() {
809        return self::is_my_jetpack_admin_request() && ! self::is_onboarding_takeover();
810    }
811
812    /**
813     * Whether this request renders the dashboard through wp-build.
814     *
815     * The generated render function is missing where the package was never built.
816     *
817     * @since 6.3.0
818     *
819     * @return bool
820     */
821    public static function should_render_wp_build() {
822        return ! self::is_onboarding_takeover()
823            && function_exists( 'jetpack_my_jetpack_my_jetpack_dashboard_wp_admin_render_page' );
824    }
825
826    /**
827     * Load wp-build for the My Jetpack dashboard.
828     *
829     * @since 6.3.0
830     *
831     * @return void
832     */
833    public static function maybe_load_wp_build() {
834        if ( ! self::should_load_wp_build() ) {
835            return;
836        }
837
838        $build_index = dirname( __DIR__ ) . '/build/build.php';
839
840        if ( file_exists( $build_index ) ) {
841            self::load_wp_build( $build_index );
842        }
843    }
844
845    /**
846     * Require the generated wp-build index and wire it into this request.
847     *
848     * @since 6.3.0
849     *
850     * @param string $build_index Path to the generated `build.php`.
851     * @return void
852     */
853    public static function load_wp_build( $build_index ) {
854        self::require_wp_build_with_screen_alias( $build_index );
855
856        // wp-build hooks module registration to wp_default_scripts, which has
857        // already fired by admin_menu â€” call it directly or the init module
858        // never reaches the import map.
859        if ( function_exists( 'jetpack_my_jetpack_register_script_modules' ) ) {
860            jetpack_my_jetpack_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
861        }
862
863        WP_Build_Polyfills::register(
864            'my-jetpack',
865            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
866        );
867    }
868
869    /**
870     * Register My Jetpack with the admin shell, which switches between Jetpack dashboards without a page load.
871     *
872     * @since $$next-version$$
873     *
874     * @param array $members Shell members keyed by admin page slug.
875     * @return array
876     */
877    public static function add_admin_shell_member( $members ) {
878        // A site that still needs onboarding is redirected to the takeover, which isn't a wp-build page.
879        if ( ! self::current_user_can_access_page() || ! file_exists( dirname( __DIR__ ) . '/build/build.php' ) || null !== self::get_onboarding_redirect_args_for_step( '' ) ) {
880            return $members;
881        }
882
883        $members['my-jetpack'] = array(
884            'build_prefix' => 'jetpack_my_jetpack',
885            'build_page'   => self::WP_BUILD_PAGE_ID,
886            'init_modules' => array( '@jetpack-my-jetpack/init' ),
887            'title'        => 'My Jetpack',
888            'capability'   => 'edit_posts',
889            'handles'      => array( 'my_jetpack_main_app', 'my_jetpack_ai_app', 'jetpack-script-data', 'jetpack-connection', 'my-jetpack-header-controls', 'wp-jp-i18n-loader' ),
890            'load'         => array( __CLASS__, 'load_as_admin_shell_guest' ),
891        );
892        return $members;
893    }
894
895    /**
896     * Ready My Jetpack's routes and page data while another dashboard's page is open.
897     *
898     * Skips the screen ID alias: on another page it would make the generated enqueue boot a second app.
899     *
900     * @since $$next-version$$
901     *
902     * @return void
903     */
904    public static function load_as_admin_shell_guest() {
905        require_once dirname( __DIR__ ) . '/build/build.php';
906
907        if ( function_exists( 'jetpack_my_jetpack_register_script_modules' ) ) {
908            jetpack_my_jetpack_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
909        }
910        WP_Build_Polyfills::register(
911            'my-jetpack',
912            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
913        );
914
915        // On the host page the shell removes this again; the guest endpoint runs it.
916        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
917    }
918
919    /**
920     * Require the generated build file with the screen ID aliased across its enqueue check.
921     *
922     * @see WP_Build_Screen_Id::load_with_alias()
923     * @param string $build_index Path to the generated `build.php`.
924     * @return void
925     */
926    private static function require_wp_build_with_screen_alias( $build_index ) {
927        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
928        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
929            WP_Build_Screen_Id::load_with_alias(
930                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
931                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
932                function () use ( $build_index ) {
933                    require_once $build_index;
934                }
935            );
936            return;
937        }
938
939        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
940        require_once $build_index;
941        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
942    }
943
944    /**
945     * Enqueue admin page assets.
946     *
947     * @return void
948     */
949    public static function enqueue_scripts() {
950        if ( self::is_offline_features_enabled() ) {
951            $data_handle = self::DATA_SCRIPT_HANDLE;
952            wp_register_script( $data_handle, false, array(), self::PACKAGE_VERSION, true );
953            wp_enqueue_script( $data_handle );
954            add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
955            wp_localize_script(
956                $data_handle,
957                'myJetpackInitialState',
958                array(
959                    'isOfflineFeatures'     => true,
960                    'products'              => array( 'items' => array() ),
961                    'adminUrl'              => esc_url( admin_url() ),
962                    'assetsUrl'             => self::get_assets_url(),
963                    'isJetpackPluginActive' => class_exists( 'Jetpack' ),
964                    'hiddenFeatures'        => Feature_Visibility::get_hidden(),
965                    'deprecatedModules'     => Feature_Visibility::get_deprecated_modules(),
966                    'myJetpackFlags'        => self::get_my_jetpack_flags(),
967                )
968            );
969            wp_localize_script(
970                $data_handle,
971                'myJetpackRest',
972                array(
973                    'apiRoot'  => esc_url_raw( rest_url() ),
974                    'apiNonce' => wp_create_nonce( 'wp_rest' ),
975                )
976            );
977            if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
978                wp_enqueue_script( 'wp-jp-i18n-loader' );
979            }
980            self::maybe_enqueue_header_controls_script();
981            return;
982        }
983
984        /**
985         * Fires after the My Jetpack page is initialized.
986         * Allows for enqueuing additional scripts only on the My Jetpack page.
987         *
988         * @since 4.35.7
989         */
990        do_action( 'myjetpack_enqueue_scripts' );
991        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
992
993        // Script-less: it only prints the state below, before the dashboard's boot runs on DOMContentLoaded.
994        $data_handle = self::DATA_SCRIPT_HANDLE;
995        wp_register_script( $data_handle, false, array(), self::PACKAGE_VERSION, true );
996        wp_enqueue_script( $data_handle );
997        self::maybe_enqueue_header_controls_script();
998
999        if ( self::is_my_jetpack_admin_request() && self::is_onboarding_takeover() ) {
1000            Assets::register_script(
1001                self::ONBOARDING_SCRIPT_HANDLE,
1002                '../build/onboarding.js',
1003                __FILE__,
1004                array(
1005                    'dependencies' => array( $data_handle ),
1006                    'enqueue'      => true,
1007                    'in_footer'    => true,
1008                    'textdomain'   => 'jetpack-my-jetpack',
1009                )
1010            );
1011        } elseif ( self::should_render_wp_build() && wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
1012            // Registered on every admin page but only enqueued when depended on; the esbuild bundles don't pull it in.
1013            wp_enqueue_script( 'wp-jp-i18n-loader' );
1014        }
1015
1016        $modules             = new Modules();
1017        $connection          = new Connection_Manager();
1018        $speed_score_history = new Speed_Score_History( home_url() );
1019        $latest_score        = $speed_score_history->latest();
1020        $previous_score      = array();
1021        if ( $speed_score_history->count() > 1 ) {
1022            $previous_score = $speed_score_history->latest( 1 );
1023        }
1024        $latest_score['previousScores'] = $previous_score['scores'] ?? array();
1025
1026        $sandboxed_domain = '';
1027        $is_dev_version   = false;
1028        if ( class_exists( 'Jetpack' ) ) {
1029            $is_dev_version   = Jetpack::is_development_version();
1030            $sandboxed_domain = defined( 'JETPACK__SANDBOX_DOMAIN' ) ? JETPACK__SANDBOX_DOMAIN : '';
1031        }
1032
1033        wp_localize_script(
1034            $data_handle,
1035            'myJetpackInitialState',
1036            array(
1037                'products'               => array(
1038                    'items' => Products::get_products(),
1039                ),
1040                'mainFeatures'           => Main_Features::get_state(),
1041                'featuresBanner'         => array( 'isDismissed' => REST_Main_Features::is_banner_dismissed() ),
1042                'onboardingWizard'       => self::get_onboarding_wizard_state(),
1043                'header'                 => self::get_header_state(),
1044                'plugins'                => Plugins_Installer::get_plugins(),
1045                'themes'                 => Sync_Functions::get_themes(),
1046                'myJetpackUrl'           => admin_url( 'admin.php?page=my-jetpack' ),
1047                'myJetpackCheckoutUri'   => admin_url( 'admin.php?page=my-jetpack' ),
1048                'topJetpackMenuItemUrl'  => Admin_Menu::get_top_level_menu_item_url(),
1049                'siteSuffix'             => ( new Status() )->get_site_suffix(),
1050                'siteUrl'                => esc_url( get_site_url() ),
1051                'homeUrl'                => esc_url_raw( home_url() ),
1052                'blogID'                 => Connection_Manager::get_site_id( true ),
1053                'myJetpackVersion'       => self::PACKAGE_VERSION,
1054                'myJetpackFlags'         => self::get_my_jetpack_flags(),
1055                'fileSystemWriteAccess'  => self::has_file_system_write_access(),
1056                'loadAddLicenseScreen'   => self::is_licensing_ui_enabled(),
1057                'adminUrl'               => esc_url( admin_url() ),
1058                'assetsUrl'              => self::get_assets_url(),
1059                'IDCContainerID'         => static::get_idc_container_id(),
1060                'userIsAdmin'            => current_user_can( 'manage_options' ),
1061                // wp_localize_script() decodes the HTML entities WordPress stores names with.
1062                'userFirstName'          => (string) wp_get_current_user()->first_name,
1063                'lifecycleStats'         => array(
1064                    'jetpackPlugins'                      => self::get_installed_jetpack_plugins(),
1065                    'historicallyActiveModules'           => \Jetpack_Options::get_option( 'historically_active_modules', array() ),
1066                    'historicallyActiveModulesRefreshDue' => Historically_Active_Modules::is_refresh_due(),
1067                    'brokenModules'                       => Red_Bubble_Notifications::check_for_broken_modules(),
1068                    'isSiteConnected'                     => $connection->is_connected(),
1069                    'isUserConnected'                     => $connection->is_user_connected(),
1070                    'modules'                             => self::get_active_modules(),
1071                ),
1072                'recommendedModules'     => array(
1073                    'modules'    => self::get_recommended_modules(),
1074                    'isFirstRun' => \Jetpack_Options::get_option( 'recommendations_first_run', true ),
1075                    'dismissed'  => \Jetpack_Options::get_option( 'dismissed_recommendations', false ),
1076                ),
1077                'isStatsModuleActive'    => $modules->is_active( 'stats' ),
1078                'canUserViewStats'       => current_user_can( 'manage_options' ) || current_user_can( 'view_stats' ),
1079                'hiddenFeatures'         => Feature_Visibility::get_hidden(),
1080                'deprecatedModules'      => Feature_Visibility::get_deprecated_modules(),
1081                'sandboxedDomain'        => $sandboxed_domain,
1082                'isDevVersion'           => $is_dev_version,
1083                'isAtomic'               => ( new Status_Host() )->is_woa_site(),
1084                'isJetpackPluginActive'  => class_exists( 'Jetpack' ),
1085                'latestBoostSpeedScores' => $latest_score,
1086                'seoOptIn'               => self::get_seo_opt_in_state(),
1087                'protectPageModuleUrls'  => (object) Products\Protect::get_protect_page_module_urls(),
1088                'partnerCoupon'          => self::get_partner_coupon_screen(),
1089            )
1090        );
1091
1092        wp_localize_script(
1093            $data_handle,
1094            'myJetpackRest',
1095            array(
1096                'apiRoot'  => esc_url_raw( rest_url() ),
1097                'apiNonce' => wp_create_nonce( 'wp_rest' ),
1098            )
1099        );
1100
1101        // Connection Initial State.
1102        Connection_Initial_State::render_script( $data_handle );
1103
1104        // Required for Analytics.
1105        if ( self::can_use_analytics() ) {
1106            Tracking::register_tracks_functions_scripts( true );
1107        }
1108    }
1109
1110    /**
1111     * What the shared header needs beyond the rest of the state, so it renders without a request.
1112     *
1113     * @since 6.10.0
1114     *
1115     * @return array{connectorsUrl: string|null} The state.
1116     */
1117    public static function get_header_state() {
1118        return array(
1119            // The same probe the connection package uses: the core screen ships with WordPress 7.0.
1120            // `connector` names the connection package's card, which opens its details for it.
1121            'connectorsUrl' => file_exists( ABSPATH . 'wp-admin/options-connectors.php' ) ? admin_url( 'options-connectors.php?connector=wordpress_com' ) : null,
1122        );
1123    }
1124
1125    /**
1126     * Add My Jetpack data to the unified script data object.
1127     *
1128     * @param array $data The script data.
1129     * @return array
1130     */
1131    public static function add_script_data( $data ) {
1132        $block_availability = ! self::is_offline_features_enabled() && class_exists( '\Jetpack_Gutenberg' )
1133            ? \Jetpack_Gutenberg::get_cached_availability()
1134            : array();
1135
1136        $data['myJetpack']['siteEditor'] = array(
1137            'isBlockTheme'            => function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(),
1138            'isSharingBlockAvailable' => isset( $block_availability['sharing-buttons'] )
1139                && $block_availability['sharing-buttons']['available'],
1140            'isLikeBlockAvailable'    => isset( $block_availability['like'] )
1141                && $block_availability['like']['available'],
1142        );
1143
1144        return $data;
1145    }
1146
1147    /**
1148     * Whether module management can use the REST API.
1149     *
1150     * @since 6.11.0
1151     * @return bool
1152     */
1153    public static function is_modules_rest_api_enabled() {
1154        /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
1155        $enabled = apply_filters( 'rest_enabled', true );
1156        $user    = wp_get_current_user();
1157        $auth    = false;
1158        // Passing true skips WordPress's nonce check, which would sign the user out; restore the user if another filter clears it.
1159        try {
1160            /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
1161            $auth = apply_filters( 'rest_authentication_errors', true );
1162        } finally {
1163            wp_set_current_user( $user->ID );
1164        }
1165        return $enabled && ! is_wp_error( $enabled ) && false !== $auth && ! is_wp_error( $auth );
1166    }
1167
1168    /**
1169     * Get a Features management link with a classic Modules fallback.
1170     *
1171     * @since 6.11.0
1172     * @param array     $args     Query arguments from a classic Modules screen link; only `s` and `module_tag` can carry over to Features.
1173     * @param bool|null $eligible Set to true when the URL points at the Features tab, false when it is the classic Modules screen.
1174     * @return string The management URL.
1175     */
1176    public static function get_modules_management_url( $args = array(), &$eligible = null ) {
1177        $eligible  = false;
1178        $available = self::host_shows_my_jetpack() && self::is_modules_to_features_enabled();
1179        $classic   = add_query_arg( array_merge( urlencode_deep( $args ), array( 'page' => 'jetpack_modules' ) ), admin_url( 'admin.php' ) );
1180        if (
1181            ! $available
1182            || array_diff( array_keys( $args ), array( 's', 'module_tag' ) )
1183            || array_filter( $args, 'is_string' ) !== $args
1184            // Keep links on Modules unless Features can show controls for this installation.
1185            || Main_Features::PLUGIN_ACTIVE !== Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG )
1186            || ! current_user_can( 'jetpack_manage_modules' )
1187            || ! current_user_can( 'manage_options' )
1188            || ! self::current_user_can_access_page()
1189            || ( new Status() )->is_offline_mode()
1190            || ( new Status_Host() )->is_wpcom_simple()
1191            || is_network_admin()
1192            || ! self::should_initialize()
1193            || ! ( new Connection_Manager() )->is_connected()
1194            || null !== self::get_partner_coupon_screen()
1195            // Predict My Jetpack's setup redirect for a Features URL with no step; keep both decisions in sync.
1196            || null !== self::get_onboarding_redirect_args_for_step( '' )
1197            || ! self::is_modules_rest_api_enabled()
1198        ) {
1199            return $classic;
1200        }
1201
1202        if ( isset( $args['module_tag'] ) && '' !== $args['module_tag'] ) {
1203            if ( ! function_exists( 'jetpack_get_module_i18n_tag' ) ) {
1204                return $classic;
1205            }
1206            $known      = array();
1207            $modules    = new Modules();
1208            $hidden     = Main_Features::get_hidden_modules();
1209            $deprecated = Feature_Visibility::get_deprecated_modules();
1210            foreach ( $modules->get_available() as $slug ) {
1211                if ( in_array( $slug, $hidden, true ) || ( in_array( $slug, $deprecated, true ) && ! $modules->is_active( $slug ) ) ) {
1212                    continue;
1213                }
1214                $module = $modules->get( $slug );
1215                $known  = array_merge( $known, $module['module_tags'] ?? array() );
1216            }
1217            $known = array_map( array( Jetpack::class, 'translate_module_tag' ), $known );
1218            if ( ! in_array( $args['module_tag'], $known, true ) ) {
1219                return $classic;
1220            }
1221        }
1222
1223        if ( isset( $args['s'] ) && '' !== $args['s'] ) {
1224            require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
1225            $hidden        = Main_Features::get_hidden_modules();
1226            $deprecated    = Feature_Visibility::get_deprecated_modules();
1227            $hidden_match  = false;
1228            $visible_match = false;
1229            foreach ( Jetpack::get_translated_modules( \Jetpack_Admin::init()->get_modules() ) as $slug => $module ) {
1230                if ( ! empty( $args['module_tag'] ) && ! in_array( $args['module_tag'], $module['module_tags'], true ) ) {
1231                    continue;
1232                }
1233                $text  = $module['name'] . ' ' . $module['description'] . ' ' . $module['long_description'] . ' ' . $module['search_terms'] . ' ' . implode( ',', $module['module_tags'] );
1234                $match = function_exists( 'mb_stripos' ) ? mb_stripos( $text, $args['s'] ) : stripos( $text, $args['s'] );
1235                if ( false === $match ) {
1236                    continue;
1237                }
1238                if ( in_array( $slug, $hidden, true ) || ( in_array( $slug, $deprecated, true ) && ! $module['activated'] ) ) {
1239                    $hidden_match = true;
1240                } else {
1241                    $visible_match = true;
1242                    break;
1243                }
1244            }
1245            if ( $hidden_match && ! $visible_match ) {
1246                return $classic;
1247            }
1248        }
1249
1250        $eligible = true;
1251        $hash     = array();
1252        if ( isset( $args['s'] ) && '' !== $args['s'] ) {
1253            $hash['search'] = $args['s'];
1254        }
1255        if ( isset( $args['module_tag'] ) && '' !== $args['module_tag'] ) {
1256            $hash['module_tag'] = $args['module_tag'];
1257        }
1258        return add_query_arg(
1259            array_merge(
1260                urlencode_deep( $args ),
1261                array(
1262                    'page'             => 'my-jetpack',
1263                    'modules_fallback' => '1',
1264                )
1265            ),
1266            admin_url( 'admin.php' )
1267        )
1268            . '#/features' . ( $hash ? '?' . http_build_query( $hash, '', '&', PHP_QUERY_RFC3986 ) : '' );
1269    }
1270
1271    /**
1272     * With `my-jetpack-modules-to-features` on, send My Jetpack requests with `modules_fallback=1` back to classic Modules:
1273     * always without JavaScript, and for everyone when REST is unavailable.
1274     *
1275     * @since 6.11.0
1276     * @return void
1277     */
1278    public static function print_modules_fallback() {
1279        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only navigation fallback.
1280        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Only the exact allowlisted marker is accepted.
1281        if ( ! self::is_modules_to_features_enabled() || 'my-jetpack' !== ( $_GET['page'] ?? null ) || '1' !== ( $_GET['modules_fallback'] ?? null ) || Main_Features::PLUGIN_ACTIVE !== Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG ) || ! current_user_can( 'jetpack_manage_modules' ) ) {
1282            return;
1283        }
1284        $args = array( 'page' => 'jetpack_modules' );
1285        foreach ( array( 's', 'module_tag' ) as $key ) {
1286            if ( isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ) {
1287                $args[ $key ] = sanitize_text_field( wp_unslash( $_GET[ $key ] ) );
1288            }
1289        }
1290        // phpcs:enable WordPress.Security.NonceVerification.Recommended
1291        $meta = '<meta http-equiv="refresh" content="0; url=' . esc_url( add_query_arg( urlencode_deep( $args ), admin_url( 'admin.php' ) ) ) . '">';
1292        echo '<noscript>' . $meta . '</noscript>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- URL escaped above.
1293        if ( ! self::is_modules_rest_api_enabled() ) {
1294            echo $meta; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- URL escaped above.
1295        }
1296    }
1297
1298    /**
1299     * Get the slug and label of the Features tab, for footer links to it.
1300     *
1301     * Kept for footers built before the Products tab was removed: Jetpack 16.3 calls this, and
1302     * older JS footers read it from script data. Without it they link to "Products".
1303     *
1304     * @since 6.4.0
1305     *
1306     * @return array{slug: string, label: string}
1307     */
1308    public static function get_products_section() {
1309        return array(
1310            'slug'  => 'features',
1311            'label' => _x( 'Features', 'Navigation item', 'jetpack-my-jetpack' ),
1312        );
1313    }
1314
1315    /**
1316     * Add My Jetpack's availability, image base URL and link targets to admin script data.
1317     *
1318     * Printed on every admin page by Script_Data, so the connection screen can resolve its
1319     * illustrations and Jetpack footers know whether to link to My Jetpack.
1320     *
1321     * @since 6.3.0
1322     *
1323     * @param array $data Script data.
1324     * @return array
1325     */
1326    public static function add_admin_script_data( $data ) {
1327        $data['myJetpack']['isAvailable']           = self::is_admin_page_available();
1328        $data['myJetpack']['assetsUrl']             = self::get_assets_url();
1329        $data['myJetpack']['productsSection']       = self::get_products_section();
1330        $data['myJetpack']['settingsConsolidation'] = self::is_settings_consolidation_enabled();
1331        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only selects which navigation data to seed.
1332        if ( in_array( $_GET['page'] ?? '', array( 'my-jetpack', 'jetpack-settings', 'jetpack' ), true ) ) {
1333            $data['myJetpack']['modulesManagementUrl'] = self::get_modules_management_url();
1334        }
1335
1336        // Only administrators can open the pages this points at, so spare everyone else the lookup.
1337        if ( current_user_can( 'manage_options' ) ) {
1338            $data['myJetpack']['aiManageUrl'] = esc_url_raw( Products\Jetpack_Ai::get_manage_url() );
1339        }
1340
1341        if (
1342            self::is_my_jetpack_admin_request()
1343            && self::is_offline_features_enabled()
1344            && self::should_initialize()
1345            && REST_Main_Features::permissions_callback()
1346        ) {
1347            $data['myJetpack']['offlineFeatures'] = array(
1348                'mainFeatures' => Main_Features::get_state( true ),
1349                'plugins'      => Plugins_Installer::get_plugins(),
1350            );
1351            $data['myJetpack']['offlineFeatures']['mainFeatures']['available_admin_pages'] = self::get_registered_admin_page_slugs();
1352        }
1353
1354        return $data;
1355    }
1356
1357    /**
1358     * Get the slugs of registered admin pages.
1359     *
1360     * Skip hooks containing `?`: they are `edit.php?post_type=...` style pages, not page slugs.
1361     *
1362     * @return string[]
1363     */
1364    private static function get_registered_admin_page_slugs() {
1365        global $_registered_pages;
1366
1367        $hooks = array_filter(
1368            array_keys( $_registered_pages ?? array() ),
1369            static fn( $hook ) => false !== strpos( $hook, '_page_' ) && false === strpos( $hook, '?' )
1370        );
1371
1372        return array_values(
1373            array_unique(
1374                array_map( static fn( $hook ) => substr( $hook, strpos( $hook, '_page_' ) + 6 ), $hooks )
1375            )
1376        );
1377    }
1378
1379    /**
1380     * Whether My Jetpack's admin page is available to the current user.
1381     *
1382     * Meaningful only after admin_menu has registered the page.
1383     *
1384     * @since 6.5.0
1385     *
1386     * @return bool
1387     */
1388    public static function is_admin_page_available() {
1389        global $_registered_pages;
1390
1391        return did_action( 'my_jetpack_init' ) > 0
1392            && isset( $_registered_pages[ get_plugin_page_hookname( 'my-jetpack', 'jetpack' ) ] )
1393            && current_user_can( 'edit_posts' );
1394    }
1395
1396    /**
1397     * Get the base URL of the package's built images, with a trailing slash.
1398     *
1399     * @since 6.3.0
1400     *
1401     * @return string
1402     */
1403    public static function get_assets_url() {
1404        return trailingslashit( Assets::normalize_path( plugins_url( '../build/images/', __FILE__ ) ) );
1405    }
1406
1407    /**
1408     * Get installed Jetpack plugins
1409     *
1410     * @return array
1411     */
1412    public static function get_installed_jetpack_plugins() {
1413        $plugin_slugs = array_keys( Plugins_Installer::get_plugins() );
1414        $plugin_slugs = array_map(
1415            static function ( $slug ) {
1416                $parts = explode( '/', $slug );
1417                // Return the last segment of the filepath without the PHP extension
1418                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
1419            },
1420            $plugin_slugs
1421        );
1422
1423        return array_values( array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs ) );
1424    }
1425
1426    /**
1427     * Get active modules (except ones enabled by default)
1428     *
1429     * @return array
1430     */
1431    public static function get_active_modules() {
1432        $modules        = new Modules();
1433        $active_modules = $modules->get_active();
1434
1435        // if the Jetpack plugin is active, filter out the modules that are active by default
1436        if ( class_exists( 'Jetpack' ) && ! empty( $active_modules ) ) {
1437            $active_modules = array_diff( $active_modules, Jetpack::get_default_modules() );
1438        }
1439        return array_values( $active_modules );
1440    }
1441
1442    /**
1443     * Determine if the current user is "new" to Jetpack
1444     * This is used to vary some messaging in My Jetpack
1445     *
1446     * On the front-end, purchases are also taken into account
1447     *
1448     * @return bool
1449     */
1450    public static function is_jetpack_user_new() {
1451        // is the user connected?
1452        $connection = new Connection_Manager();
1453        if ( $connection->is_user_connected() ) {
1454            return false;
1455        }
1456
1457        // TODO: add a data point for the last known connection/ disconnection time
1458
1459        // are any modules active?
1460        $active_modules = self::get_active_modules();
1461        if ( ! empty( $active_modules ) ) {
1462            return false;
1463        }
1464
1465        // check for other Jetpack plugins that are installed on the site (active or not)
1466        // If there's more than one Jetpack plugin active, this user is not "new"
1467        $plugin_slugs              = array_keys( Plugins_Installer::get_plugins() );
1468        $plugin_slugs              = array_map(
1469            static function ( $slug ) {
1470                $parts = explode( '/', $slug );
1471                // Return the last segment of the filepath without the PHP extension
1472                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
1473            },
1474            $plugin_slugs
1475        );
1476        $installed_jetpack_plugins = array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs );
1477        if ( is_countable( $installed_jetpack_plugins ) && count( $installed_jetpack_plugins ) >= 2 ) {
1478            return false;
1479        }
1480
1481        // Does the site have any purchases?
1482        $purchases = Wpcom_Products::get_site_current_purchases();
1483        if ( ! empty( $purchases ) && ! is_wp_error( $purchases ) ) {
1484            return false;
1485        }
1486
1487        return true;
1488    }
1489
1490    /**
1491     * Register My Jetpack's feature flags.
1492     *
1493     * @since 6.11.0
1494     *
1495     * @return void
1496     */
1497    public static function register_feature_flags() {
1498        Feature_Flags::register(
1499            self::ACCOUNT_CONNECT_FLAG,
1500            array(
1501                'default'     => true,
1502                'description' => 'Connect the account from the Install or Connect button of Features cards that need one, turn the chosen module on once Install Jetpack or Activate Jetpack finishes setup, hide Active and Inactive where the control already shows the state, exclude features from Available when their only action is an install the user cannot make, and show host restrictions below card descriptions.',
1503                'owner'       => 'my-jetpack',
1504            )
1505        );
1506        Feature_Flags::register(
1507            self::HEADER_CONTROLS_FLAG,
1508            array(
1509                'default'     => true,
1510                'description' => 'Show the global controls in the shared header of My Jetpack and Boost.',
1511                'owner'       => 'my-jetpack',
1512            )
1513        );
1514        Feature_Flags::register(
1515            self::OVERVIEW_V1_FLAG,
1516            array(
1517                'default'     => false,
1518                'description' => 'Show Overview v1 in My Jetpack.',
1519                'owner'       => 'my-jetpack',
1520            )
1521        );
1522        Feature_Flags::register(
1523            self::MODULES_TO_FEATURES_FLAG,
1524            array(
1525                'default'     => true,
1526                'description' => 'Open supported classic Modules links in Features.',
1527                'owner'       => 'my-jetpack',
1528            )
1529        );
1530        Feature_Flags::register(
1531            self::SETTINGS_CONSOLIDATION_FLAG,
1532            array(
1533                'default'     => false,
1534                'description' => 'Move settings out of the Settings screen to their new homes: open small features\' settings in a panel from Configure in Features, remove or replace their Settings cards with pointers, and show settings in their new places.',
1535                'owner'       => 'my-jetpack',
1536            )
1537        );
1538    }
1539
1540    /**
1541     * Whether the shared header shows its controls: the flag is on, My Jetpack runs, and not in offline Features.
1542     *
1543     * Boost's dashboard gets My Jetpack's page state even where My Jetpack is off, hence the second check.
1544     * Offline Features prints no connection state for the controls to read, and has no connection to show.
1545     *
1546     * @return bool
1547     */
1548    private static function is_header_controls_enabled() {
1549        return Feature_Flags::is_enabled( self::HEADER_CONTROLS_FLAG )
1550            && self::should_initialize()
1551            && ! self::is_offline_features_enabled();
1552    }
1553
1554    /**
1555     * Give dashboards outside My Jetpack, such as Boost's, the header's controls as a shared script.
1556     *
1557     * Bundling them instead would ship a second copy of the connection package next to the page's own.
1558     *
1559     * @return void
1560     */
1561    private static function maybe_enqueue_header_controls_script() {
1562        if ( self::is_my_jetpack_admin_request() || ! self::is_header_controls_enabled() ) {
1563            return;
1564        }
1565
1566        Assets::register_script(
1567            self::HEADER_CONTROLS_SCRIPT_HANDLE,
1568            '../build/header-controls.js',
1569            __FILE__,
1570            array(
1571                'dependencies' => array( self::DATA_SCRIPT_HANDLE ),
1572                'enqueue'      => true,
1573                'in_footer'    => true,
1574                'textdomain'   => 'jetpack-my-jetpack',
1575            )
1576        );
1577    }
1578
1579    /**
1580     *  Build flags for My Jetpack UI
1581     *
1582     *  @return array
1583     */
1584    public static function get_my_jetpack_flags() {
1585        $flags = array(
1586            'videoPressStats'          => Jetpack_Constants::is_true( 'JETPACK_MY_JETPACK_VIDEOPRESS_STATS_ENABLED' ),
1587            'showFullJetpackStatsCard' => class_exists( 'Jetpack' ) || Products\Stats::is_standalone_plugin_active(),
1588            // Only says which destination `manage_url` is: the legacy Stats page
1589            // caches its report and wants a `force_refresh` hint the dashboard does not.
1590            'premiumAnalyticsEnabled'  => Products\Stats::is_premium_analytics_enabled(),
1591            'showAiModuleToggle'       => Products\Jetpack_Ai::is_feature_ui_enabled(),
1592            'headerControls'           => self::is_header_controls_enabled(),
1593            'accountConnect'           => Feature_Flags::is_enabled( self::ACCOUNT_CONNECT_FLAG ),
1594            'overviewV1'               => self::is_overview_v1_enabled(),
1595        );
1596
1597        if ( $flags['accountConnect'] ) {
1598            $flags['showAiModuleToggleOnceConnected'] = Products\Jetpack_Ai::is_feature_ui_waiting_for_owner();
1599        }
1600
1601        return $flags;
1602    }
1603
1604    /**
1605     * Whether My Jetpack shows Overview v1.
1606     *
1607     * @since 6.10.0
1608     * @return bool
1609     */
1610    public static function is_overview_v1_enabled() {
1611        return Feature_Flags::is_enabled( self::OVERVIEW_V1_FLAG );
1612    }
1613
1614    /**
1615     * Whether the host shows My Jetpack.
1616     *
1617     * @return bool
1618     */
1619    private static function host_shows_my_jetpack() {
1620        /**
1621         * Filters whether the host shows My Jetpack, which lets Modules links open Features and settings move to My Jetpack.
1622         *
1623         * @since 6.11.0
1624         * @since 6.17.1 False also keeps settings on the Settings screen.
1625         * @param bool $available True to allow the Features tab; false keeps the classic Modules and Settings screens, for a host where My Jetpack is not shown.
1626         */
1627        return (bool) apply_filters( 'jetpack_my_jetpack_modules_management_available', true );
1628    }
1629
1630    /**
1631     * Whether settings move to My Jetpack on this site: the flag is on, My Jetpack initializes, and the host shows it.
1632     *
1633     * It needs no admin menu, so URL builders that also run in REST use it, while page loads use `is_settings_consolidation_enabled()`.
1634     *
1635     * @since 6.17.1
1636     *
1637     * @return bool
1638     */
1639    public static function is_settings_consolidation_on_for_site() {
1640        return Feature_Flags::is_enabled( self::SETTINGS_CONSOLIDATION_FLAG )
1641            && self::should_initialize()
1642            && self::host_shows_my_jetpack();
1643    }
1644
1645    /**
1646     * Whether settings move to My Jetpack: consolidation is on for the site and My Jetpack's page is available.
1647     *
1648     * Without the page, moved settings would have nowhere to go, so they stay on the Settings screen.
1649     *
1650     * @since 6.15.0
1651     *
1652     * @return bool
1653     */
1654    public static function is_settings_consolidation_enabled() {
1655        return self::is_settings_consolidation_on_for_site() && self::is_admin_page_available();
1656    }
1657
1658    /**
1659     * Point the module's configure URL at its settings panel while settings consolidation is on for the site.
1660     *
1661     * @since 6.16.0
1662     *
1663     * @return void
1664     */
1665    public static function register_settings_panel_urls() {
1666        foreach ( self::SETTINGS_PANEL_MODULES as $module ) {
1667            add_filter(
1668                'jetpack_module_configuration_url_' . $module,
1669                static function ( $url ) use ( $module ) {
1670                    // Only Jetpack's settings-search default: a module's own page, such as SEO's, is its real home.
1671                    return false === strpos( (string) $url, 'page=jetpack-settings#/settings?term=' )
1672                        ? $url
1673                        : self::resolve_settings_url( $module, $url );
1674                }
1675            );
1676        }
1677    }
1678
1679    /**
1680     * Where a module's settings live: its panel in Features while settings consolidation is on for the site, `$fallback` otherwise.
1681     *
1682     * @since 6.16.0
1683     *
1684     * @param string $module   The module slug.
1685     * @param string $fallback The URL to use when the settings stay where they were.
1686     * @return string
1687     */
1688    public static function resolve_settings_url( $module, $fallback ) {
1689        if ( ! in_array( $module, self::SETTINGS_PANEL_MODULES, true ) || ! self::is_settings_consolidation_on_for_site() ) {
1690            return $fallback;
1691        }
1692
1693        return add_query_arg(
1694            array(
1695                'page' => 'my-jetpack',
1696                'p'    => rawurlencode( '/features?configure=' . $module ),
1697            ),
1698            admin_url( 'admin.php' )
1699        );
1700    }
1701
1702    /**
1703     * Whether Modules links may use Features.
1704     *
1705     * @return bool
1706     */
1707    private static function is_modules_to_features_enabled() {
1708        return Feature_Flags::is_enabled( self::MODULES_TO_FEATURES_FLAG );
1709    }
1710
1711    /**
1712     * Build the state the My Jetpack "try the new SEO experience" opt-in card hydrates from.
1713     *
1714     * The card invites an existing self-hosted install to switch over to the new Jetpack SEO
1715     * dashboard (JETPACK-1700). Gating lives server-side, where the signals actually are. The
1716     * card shows only when all of:
1717     *
1718     * - the new SEO product is available â€” the `rsm_jetpack_seo` feature filter is on (the SEO
1719     *   package autoloads regardless, so `class_exists()` alone isn't enough; the filter is the
1720     *   real availability switch and the same one the SEO package gates its own surface behind);
1721     * - the site is self-hosted â€” WordPress.com (Simple + Atomic) decides its own SEO surface, so
1722     *   the opt-in card is for self-hosted installs only;
1723     * - the install hasn't opted in yet â€” the SEO surface isn't visible. On wpcom
1724     *   the SEO package's `is_seo_surface_visible()` short-circuits to `true`, so the
1725     *   "not visible yet" check also doubles as the self-hosted guard, but we check the platform
1726     *   explicitly for clarity.
1727     *
1728     * Referenced through `class_exists()` rather than a hard composer dependency: both packages
1729     * ship inside the Jetpack plugin and the SEO surface is feature-flagged, so a guarded read of
1730     * its public API keeps this from adding plumbing to consumers that don't load SEO.
1731     *
1732     * The on-success destination is computed by the opt-in endpoint itself; we only seed the card
1733     * with the same admin URL so the button has a sensible fallback before the request resolves.
1734     *
1735     * @return array{showCard: bool, redirect: string}
1736     */
1737    public static function get_seo_opt_in_state() {
1738        // Guard with method_exists rather than class_exists: an older bundled SEO package can ship
1739        // the Initializer class without this method, and class_exists alone would still fatal.
1740        $seo_initializer = 'Automattic\Jetpack\SEO\Initializer';
1741        // @phan-suppress-next-line PhanUndeclaredClassReference -- optional SEO package, guarded by method_exists.
1742        $show_card = method_exists( $seo_initializer, 'is_optin_available' ) && $seo_initializer::is_optin_available();
1743
1744        return array(
1745            'showCard' => $show_card,
1746            'redirect' => admin_url( 'admin.php?page=jetpack-seo' ),
1747        );
1748    }
1749
1750    /**
1751     * Echoes the admin page content.
1752     *
1753     * @return void
1754     */
1755    public static function admin_page() {
1756        // Asked again here rather than inferred from the redirect: with the wizard on a
1757        // connected user is deliberately left on this screen to finish setup, and anyone
1758        // who cannot connect never reaches it.
1759        if ( self::is_onboarding_takeover() ) {
1760            echo '<div id="my-jetpack-container"></div>';
1761            return;
1762        }
1763
1764        if ( self::should_render_wp_build() ) {
1765            jetpack_my_jetpack_my_jetpack_dashboard_wp_admin_render_page(); // @phan-suppress-current-line PhanUndeclaredFunction -- should_render_wp_build() checks function_exists(); defined in the generated build/pages/, which Phan excludes.
1766        }
1767    }
1768
1769    /**
1770     * Register the REST API routes.
1771     *
1772     * @return void
1773     */
1774    public static function register_rest_endpoints() {
1775        if ( self::is_offline_features_enabled() ) {
1776            ( new REST_Main_Features() )->register_rest_routes();
1777            return;
1778        }
1779
1780        new REST_Products();
1781        new REST_Purchases();
1782        ( new REST_Jetpack_AI_JWT() )->register_rest_route();
1783        new REST_Recommendations_Evaluation();
1784
1785        ( new REST_Onboarding() )->register_rest_routes();
1786        ( new REST_Main_Features() )->register_rest_routes();
1787
1788        Products::register_product_endpoints();
1789        Historically_Active_Modules::register_rest_endpoints();
1790        Jetpack_Manage::register_rest_endpoints();
1791        Red_Bubble_Notifications::register_rest_endpoints();
1792
1793        if ( self::is_overview_v1_enabled() ) {
1794            REST_Blog_Posts::register_rest_endpoints();
1795        }
1796
1797        register_rest_route(
1798            'my-jetpack/v1',
1799            'site',
1800            array(
1801                'methods'             => \WP_REST_Server::READABLE,
1802                'callback'            => __CLASS__ . '::get_site',
1803                'permission_callback' => __CLASS__ . '::permissions_callback',
1804            )
1805        );
1806
1807        register_rest_route(
1808            'my-jetpack/v1',
1809            'site/dismiss-welcome-banner',
1810            array(
1811                'methods'             => \WP_REST_Server::EDITABLE,
1812                'callback'            => __CLASS__ . '::dismiss_welcome_banner',
1813                'permission_callback' => __CLASS__ . '::permissions_callback',
1814            )
1815        );
1816    }
1817
1818    /**
1819     * Check user capability to access the endpoint.
1820     *
1821     * @access public
1822     * @static
1823     *
1824     * @return true|WP_Error
1825     */
1826    public static function permissions_callback() {
1827        return current_user_can( 'manage_options' );
1828    }
1829
1830    /**
1831     * Return true if we should initialize the My Jetpack admin page.
1832     */
1833    public static function should_initialize() {
1834        $should = true;
1835
1836        if ( ( new Status() )->is_offline_mode() ) {
1837            $should = self::is_offline_features_enabled();
1838        }
1839
1840        /**
1841         * Allows filtering whether My Jetpack should be initialized.
1842         * Outside REST requests, once the user is set, this cannot enable offline Features for a user without access.
1843         *
1844         * @since 0.5.0-alpha
1845         *
1846         * @param bool $shoud_initialize Should we initialize My Jetpack?
1847         */
1848        $should = apply_filters( 'jetpack_my_jetpack_should_initialize', $should );
1849
1850        // REST authenticates after route registration; its permission callbacks check access.
1851        if ( self::is_offline_features_enabled() && ! Jetpack_Constants::is_true( 'REST_REQUEST' ) && function_exists( 'wp_get_current_user' ) && did_action( 'set_current_user' ) && ! self::current_user_can_access_page() ) {
1852            return false;
1853        }
1854
1855        return $should;
1856    }
1857
1858    /**
1859     * Whether the site uses the local Features entry instead of the connected dashboard.
1860     *
1861     * @since 6.10.0
1862     * @return bool
1863     */
1864    public static function is_offline_features_enabled() {
1865        if ( ! ( new Status() )->is_offline_mode() ) {
1866            return false;
1867        }
1868
1869        /**
1870         * Enable local Features data and entry in offline mode.
1871         *
1872         * @since 6.10.0
1873         * @since 6.15.0 Enabled by default.
1874         * @param bool $enabled Whether to enable offline Features. Default true.
1875         */
1876        return (bool) apply_filters( 'jetpack_my_jetpack_offline_features', true );
1877    }
1878
1879    /**
1880     * Hook into several connection-based actions to update the historically active Jetpack modules
1881     * If the transient that indicates the list needs to be synced, update it and delete the transient
1882     *
1883     * @return void
1884     */
1885    public static function setup_historically_active_jetpack_modules_sync() {
1886        // yummmm. ham.
1887        $ham = new Historically_Active_Modules();
1888        if ( get_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY ) && ! wp_doing_ajax() ) {
1889            $ham::update_historically_active_jetpack_modules();
1890            delete_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY );
1891        }
1892
1893        $actions = array(
1894            'jetpack_site_registered',
1895            'jetpack_user_authorized',
1896            'activated_plugin',
1897        );
1898
1899        foreach ( $actions as $action ) {
1900            add_action( $action, array( $ham, 'queue_historically_active_jetpack_modules_update' ), 5 );
1901        }
1902
1903        // Modules are often updated async, so we need to update them right away as there will sometimes be no page reload.
1904        add_action( 'jetpack_activate_module', array( $ham, 'update_historically_active_jetpack_modules' ), 5 );
1905    }
1906
1907    /**
1908     * Site full-data endpoint.
1909     *
1910     * @return object Site data.
1911     */
1912    public static function get_site() {
1913        $site_id           = \Jetpack_Options::get_option( 'id' );
1914        $wpcom_endpoint    = sprintf( '/sites/%d?force=wpcom', $site_id );
1915        $wpcom_api_version = '1.1';
1916        $response          = Client::wpcom_json_api_request_as_blog( $wpcom_endpoint, $wpcom_api_version );
1917        $response_code     = wp_remote_retrieve_response_code( $response );
1918        $body              = json_decode( wp_remote_retrieve_body( $response ) );
1919
1920        if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
1921            return new WP_Error( 'site_data_fetch_failed', 'Site data fetch failed', array( 'status' => $response_code ) );
1922        }
1923
1924        return rest_ensure_response( $body );
1925    }
1926
1927    /**
1928     * Populates the self::$site_info var with site data from the /sites/%d endpoint
1929     *
1930     * @return object|WP_Error
1931     */
1932    public static function get_site_info() {
1933        static $site_info = null;
1934
1935        if ( $site_info !== null ) {
1936            return $site_info;
1937        }
1938
1939        // Check for a cached value before doing lookup
1940        $stored_site_info = get_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY );
1941        if ( $stored_site_info !== false ) {
1942            return $stored_site_info;
1943        }
1944
1945        $response = self::get_site();
1946        if ( is_wp_error( $response ) ) {
1947            return $response;
1948        }
1949        $site_info = $response->data;
1950        set_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY, $site_info, DAY_IN_SECONDS );
1951
1952        return $site_info;
1953    }
1954
1955    /**
1956     * Returns whether a site has been determined "commercial" or not.
1957     *
1958     * @return bool|null
1959     */
1960    public static function is_commercial_site() {
1961        if ( is_wp_error( self::$site_info ) ) {
1962            return null;
1963        }
1964
1965        return empty( self::$site_info->options->is_commercial ) ? false : self::$site_info->options->is_commercial;
1966    }
1967
1968    /**
1969     * Check if site is registered (has been connected before).
1970     *
1971     * @return bool
1972     */
1973    public static function is_registered() {
1974        return (bool) \Jetpack_Options::get_option( 'id' );
1975    }
1976
1977    /**
1978     * Dismiss the welcome banner.
1979     *
1980     * @return \WP_REST_Response
1981     */
1982    public static function dismiss_welcome_banner() {
1983        \Jetpack_Options::update_option( 'dismissed_welcome_banner', true );
1984        return rest_ensure_response( array( 'success' => true ) );
1985    }
1986
1987    /**
1988     * Returns "yes" if the site has file write access to the plugins folder, "no" otherwise.
1989     *
1990     * @return string
1991     **/
1992    public static function has_file_system_write_access() {
1993
1994        $cache = get_transient( 'my_jetpack_write_access' );
1995
1996        if ( false !== $cache ) {
1997            return $cache;
1998        }
1999
2000        if ( ! function_exists( 'get_filesystem_method' ) ) {
2001            require_once ABSPATH . 'wp-admin/includes/file.php';
2002        }
2003
2004        require_once ABSPATH . 'wp-admin/includes/template.php';
2005
2006        $write_access = 'no';
2007
2008        $filesystem_method = get_filesystem_method( array(), WP_PLUGIN_DIR );
2009        if ( 'direct' === $filesystem_method ) {
2010            $write_access = 'yes';
2011        }
2012
2013        if ( 'no' === $write_access ) {
2014            ob_start();
2015            $filesystem_credentials_are_stored = request_filesystem_credentials( self_admin_url() );
2016            ob_end_clean();
2017
2018            if ( $filesystem_credentials_are_stored ) {
2019                $write_access = 'yes';
2020            }
2021        }
2022
2023        set_transient( 'my_jetpack_write_access', $write_access, 30 * MINUTE_IN_SECONDS );
2024
2025        return $write_access;
2026    }
2027
2028    /**
2029     * Get container IDC for the IDC screen.
2030     *
2031     * @return string
2032     */
2033    public static function get_idc_container_id() {
2034        return static::IDC_CONTAINER_ID;
2035    }
2036
2037    /**
2038     * Conditionally append the red bubble notification to the "Jetpack" menu item if there are alerts to show.
2039     *
2040     * On My Jetpack page: Uses blocking behavior to fetch fresh data.
2041     * On other admin pages: Uses cached data only to avoid blocking, with async JS fetch if cache is empty.
2042     *
2043     * @return void
2044     */
2045    public static function maybe_show_red_bubble() {
2046        global $pagenow;
2047
2048        // Don't show red bubble alerts for non-admin users
2049        // These alerts are generally only actionable for admins
2050        if ( ! current_user_can( 'manage_options' ) ) {
2051            return;
2052        }
2053
2054        // Don't show any red bubbles when Jetpack is disconnected
2055        // Users can't act on most alerts without a connection
2056        $connection = new Connection_Manager();
2057        if ( ! $connection->is_connected() ) {
2058            return;
2059        }
2060
2061        // Check if we're on the My Jetpack page.
2062        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2063        $page               = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
2064        $is_my_jetpack_page = $pagenow === 'admin.php' && $page === 'my-jetpack';
2065
2066        if ( $is_my_jetpack_page ) {
2067            // On My Jetpack page: use blocking behavior for fresh data.
2068            add_filter( 'my_jetpack_red_bubble_notification_slugs', array( Red_Bubble_Notifications::class, 'add_red_bubble_alerts' ) );
2069            $red_bubble_alerts = Red_Bubble_Notifications::get_red_bubble_alerts();
2070        } else {
2071            // On other pages: use cached data only to avoid blocking.
2072            $cached_alerts = Red_Bubble_Notifications::get_cached_alerts();
2073
2074            if ( false === $cached_alerts ) {
2075                // No cache: warm it asynchronously via JS. Register a hidden zero-count
2076                // placeholder so Menu_Renderer emits a `my-jetpack` badge element the
2077                // warmer can reveal (see async-notification-bubble.ts) without a reload.
2078                Menu_Badges::init(); // idempotent; wires the renderer.
2079                Notification_Counts::register(
2080                    'my-jetpack',
2081                    array(
2082                        'menu_slug' => 'my-jetpack',
2083                        'count'     => 0,
2084                        'type'      => 'count',
2085                    )
2086                );
2087                add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_red_bubble_script' ) );
2088                return;
2089            }
2090
2091            $red_bubble_alerts = $cached_alerts;
2092        }
2093
2094        // Filter out silent alerts.
2095        $red_bubble_alerts = array_filter(
2096            $red_bubble_alerts,
2097            function ( $alert ) {
2098                return empty( $alert['is_silent'] );
2099            }
2100        );
2101
2102        // Report each non-silent alert to the central menu-badges registry as an
2103        // attention entry (count 1). The registry + renderer own the badge.
2104        Menu_Badges::init(); // idempotent; wires the renderer.
2105
2106        // Connection errors are owned by the Error Handler (the single source of truth):
2107        // surface any it reports for this viewer as their own attention entry. Read live
2108        // rather than from the red-bubble transient, since that cache is not viewer-keyed.
2109        $has_connection_error = self::has_connection_error();
2110        if ( $has_connection_error ) {
2111            Notification_Counts::register(
2112                'my-jetpack-connection-error',
2113                array(
2114                    'menu_slug' => 'my-jetpack',
2115                    'type'      => 'attention',
2116                )
2117            );
2118        }
2119
2120        $counted_elsewhere = self::get_alerts_counted_elsewhere();
2121        foreach ( array_keys( $red_bubble_alerts ) as $slug ) {
2122            if ( in_array( $slug, $counted_elsewhere, true ) ) {
2123                continue;
2124            }
2125            // The missing-connection slug and a connection error describe the same broken
2126            // connection; count it once (the error, above, is the more specific signal).
2127            if ( $has_connection_error && 'missing-connection' === $slug ) {
2128                continue;
2129            }
2130            Notification_Counts::register(
2131                'my-jetpack-' . $slug,
2132                array(
2133                    'menu_slug' => 'my-jetpack',
2134                    'type'      => 'attention',
2135                )
2136            );
2137        }
2138    }
2139
2140    /**
2141     * Whether the Connection Error Handler reports a displayable connection error
2142     * for the current viewer.
2143     *
2144     * Read live (not via the red-bubble transient): get_displayable_errors() is a
2145     * cached option read that the Error Handler already scopes and caches per viewer.
2146     * Guarded for the mid-plugin-update window, where a stale connection package
2147     * predating the method can be loaded.
2148     *
2149     * @return bool
2150     */
2151    private static function has_connection_error() {
2152        if ( ! class_exists( Connection_Error_Handler::class ) || ! method_exists( Connection_Error_Handler::class, 'get_displayable_errors' ) ) {
2153            return false;
2154        }
2155        return ! empty( Connection_Error_Handler::get_instance()->get_displayable_errors() );
2156    }
2157
2158    /**
2159     * Alerts left out of the My Jetpack badge because another menu item already counts them.
2160     *
2161     * The Protect page reports its own count, from the standalone plugin (class-jetpack-protect.php::admin_page_init())
2162     * or the Protect package's dashboard; without either, nobody else counts the threats, so they must stay.
2163     *
2164     * @return string[] Alert slugs.
2165     */
2166    private static function get_alerts_counted_elsewhere() {
2167        // Without has_protect_page(), use the standalone plugin check this relied on before that method existed.
2168        $has_protect_page = method_exists( Products\Protect::class, 'has_protect_page' ) ? Products\Protect::has_protect_page() : Products\Protect::is_standalone_plugin_active();
2169        return $has_protect_page ? array( 'protect_has_threats' ) : array();
2170    }
2171
2172    /**
2173     * Enqueue the notification bubble script.
2174     * Fetches fresh alert data via REST API without blocking page load.
2175     *
2176     * @return void
2177     */
2178    public static function enqueue_red_bubble_script() {
2179        Assets::register_script(
2180            'my-jetpack-notification-bubble',
2181            '../build/async-notification-bubble.js',
2182            __FILE__,
2183            array(
2184                'enqueue'   => true,
2185                'in_footer' => true,
2186            )
2187        );
2188        wp_localize_script(
2189            'my-jetpack-notification-bubble',
2190            'myJetpackNotificationBubble',
2191            array( 'excludedAlerts' => self::get_alerts_counted_elsewhere() )
2192        );
2193    }
2194
2195    /**
2196     * Get list of module names sorted by their recommendation score
2197     *
2198     * @return array|null
2199     */
2200    public static function get_recommended_modules() {
2201        $recommendations_evaluation = \Jetpack_Options::get_option( 'recommendations_evaluation', null );
2202
2203        if ( empty( $recommendations_evaluation ) || ! is_array( $recommendations_evaluation ) ) {
2204            return null;
2205        }
2206
2207        arsort( $recommendations_evaluation ); // Sort by scores in descending order
2208
2209        return array_keys( $recommendations_evaluation ); // Get only module names
2210    }
2211}