Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.57% covered (warning)
51.57%
247 / 479
26.92% covered (danger)
26.92%
7 / 26
CRAP
n/a
0 / 0
wpcom_register_native_activity_log_flag
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
current_user_has_wpcom_account
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
wpcom_add_dashboard_updates_menu
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
wpcom_display_dashboard_updates_page
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
wpcom_can_link_to_calypso
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_add_my_home_menu
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
wpcom_add_hosting_menu
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
wpcom_admin_menu_enqueue_styles
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
wpcom_add_upgrades_menu
92.96% covered (success)
92.96%
66 / 71
0.00% covered (danger)
0.00%
0 / 1
3.00
wpcom_get_current_plan_name
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
7.19
wpcom_reorder_submenu
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
jetpack_mu_wpcom_redirects_my_jetpack_to_stats
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
wpcom_jetpack_menu_has_my_jetpack
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_backup_opens_in_wp_admin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
wpcom_add_jetpack_submenu
94.59% covered (success)
94.59%
70 / 74
0.00% covered (danger)
0.00%
0 / 1
18.05
wpcom_sort_jetpack_submenu
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
10
wpcom_add_native_activity_log_submenu
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
wpcom_hide_customizer_submenu_on_block_theme
8.33% covered (danger)
8.33%
1 / 12
0.00% covered (danger)
0.00%
0 / 1
16.32
wpcom_maybe_enable_link_manager
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
wpcom_hide_submenu_page
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
wpcom_add_plugins_menu
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
182
wpcom_add_tools_menu
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
12
wpcom_redirect_retired_marketing_page
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
wpcom_display_export_erase_personal_data_page
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
wpcom_display_site_health_page
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
wpcom_add_settings_menu
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * WordPress.com admin menu
4 *
5 * Adds WordPress.com-specific stuff to WordPress admin menu.
6 *
7 * @package automattic/jetpack-mu-wpcom
8 */
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Feature_Flags\Feature_Flags;
12use Automattic\Jetpack\Jetpack_Mu_Wpcom\WPCOM_Backup;
13use Automattic\Jetpack\Modules;
14use Automattic\Jetpack\Newsletter\Settings as Newsletter_Settings;
15use Automattic\Jetpack\Podcast\Admin_Page as Podcast_Admin_Page;
16use Automattic\Jetpack\Redirect;
17
18require_once __DIR__ . '/../../common/wpcom-callout.php';
19require_once __DIR__ . '/../../common/launchpad-no-guidance.php';
20
21/**
22 * Feature flag gating the native Activity Log page on Simple sites.
23 */
24const WPCOM_NATIVE_ACTIVITY_LOG_FLAG = 'wpcom-native-activity-log';
25
26/**
27 * Registers the feature flag.
28 *
29 * Runs as this file loads, so the flag is listed wherever flags are read or toggled.
30 *
31 * @return void
32 */
33function wpcom_register_native_activity_log_flag() {
34    Feature_Flags::register(
35        WPCOM_NATIVE_ACTIVITY_LOG_FLAG,
36        array(
37            'default'     => false,
38            'description' => 'Show the native wp-admin Activity Log page on Simple sites instead of linking to Calypso.',
39            'owner'       => 'jetpack-mu-wpcom',
40        )
41    );
42}
43wpcom_register_native_activity_log_flag();
44
45/**
46 * Checks if the current user has a WordPress.com account connected.
47 *
48 * @return bool
49 */
50function current_user_has_wpcom_account() {
51    $user_id = get_current_user_id();
52
53    if ( function_exists( '\A8C\Billingdaddy\Users\get_wpcom_user' ) ) {
54        // On Simple sites, use get_wpcom_user function to check if the user has a WordPress.com account.
55        $user        = \A8C\Billingdaddy\Users\get_wpcom_user( $user_id );
56        $has_account = isset( $user->ID );
57    } else {
58        // On Atomic sites, use the Connection Manager to check if the user has a WordPress.com account.
59        $connection_manager = new Connection_Manager();
60        $wpcom_user_data    = $connection_manager->get_connected_user_data( $user_id );
61        $has_account        = isset( $wpcom_user_data['ID'] );
62    }
63
64    return $has_account;
65}
66
67/**
68 * Adds the Dashboard > Updates menu on Simple sites
69 */
70function wpcom_add_dashboard_updates_menu() {
71    $is_simple_site = defined( 'IS_WPCOM' ) && IS_WPCOM;
72    if ( ! $is_simple_site || ( function_exists( 'wpcom_is_vip' ) && wpcom_is_vip() ) ) {
73        return;
74    }
75
76    add_submenu_page(
77        'index.php',
78        __( 'WordPress Updates', 'jetpack-mu-wpcom' ),
79        __( 'Updates', 'jetpack-mu-wpcom' ),
80        'manage_options',
81        'wpcom-dashboard-updates',
82        'wpcom_display_dashboard_updates_page'
83    );
84}
85add_action( 'admin_menu', 'wpcom_add_dashboard_updates_menu' );
86
87/**
88 * Displays a WordPress Updates page for Simple sites.
89 */
90function wpcom_display_dashboard_updates_page() {
91    ?>
92    <div class="wrap">
93        <h1><?php esc_html_e( 'WordPress Updates', 'jetpack-mu-wpcom' ); ?></h1>
94        <p><?php esc_html_e( "WordPress.com automatically keeps your site's plugins, themes, and WordPress version up to date.", 'jetpack-mu-wpcom' ); ?></p>
95        <h2><?php esc_html_e( 'WordPress', 'jetpack-mu-wpcom' ); ?></h2>
96        <p><?php esc_html_e( 'Your version of WordPress is up to date.', 'jetpack-mu-wpcom' ); ?></p>
97        <h2><?php esc_html_e( 'Plugins', 'jetpack-mu-wpcom' ); ?></h2>
98        <p><?php esc_html_e( 'Your plugins are all up to date.', 'jetpack-mu-wpcom' ); ?>
99        <h2><?php esc_html_e( 'Themes', 'jetpack-mu-wpcom' ); ?></h2>
100        <p><?php esc_html_e( 'Your themes are all up to date.', 'jetpack-mu-wpcom' ); ?>
101    </div>
102    <?php
103}
104
105/**
106 * Checks if menu items can link to Calypso.
107 *
108 * This way we can avoid a broken nav experience for super admins who are not members of the current site,
109 * since Calypso doesn't support this flow.
110 */
111function wpcom_can_link_to_calypso() {
112    return is_user_member_of_blog();
113}
114
115/**
116 * Adds a My Home menu.
117 */
118function wpcom_add_my_home_menu() {
119    if ( ! wpcom_can_link_to_calypso() ) {
120        return;
121    }
122
123    // My Home only goes with the legacy launchpad: AI Launchpad and no-guidance sites drop it for every user.
124    // Removing it here also drops it from the Calypso sidebar.
125    if ( get_option( 'wpcom_ai_launchpad_enabled' ) || wpcom_launchpad_is_no_guidance() ) {
126        return;
127    }
128
129    $domain = wp_parse_url( home_url(), PHP_URL_HOST );
130    // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
131    add_menu_page( __( 'My Home', 'jetpack-mu-wpcom' ), __( 'My Home', 'jetpack-mu-wpcom' ), 'read', 'https://wordpress.com/home/' . $domain, null, 'dashicons-admin-home', 2.01 ); // The 2.01 position is to ensure it's above the VIP menu on P2 sites.'
132}
133add_action( 'admin_menu', 'wpcom_add_my_home_menu' );
134
135/**
136 * Adds a Hosting menu.
137 */
138function wpcom_add_hosting_menu() {
139    $domain = wp_parse_url( home_url(), PHP_URL_HOST );
140
141    $menu_title = sprintf(
142        '%1$s<span class="inline-icon dashicons dashicons-external"></span>',
143        __( 'Hosting', 'jetpack-mu-wpcom' )
144    );
145
146    add_menu_page(
147        __( 'Hosting', 'jetpack-mu-wpcom' ),
148        $menu_title,
149        'manage_options',
150        esc_url( "https://wordpress.com/overview/$domain" ),
151        null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
152        'dashicons-cloud',
153        2.98
154    );
155}
156add_action( 'admin_menu', 'wpcom_add_hosting_menu' );
157
158/**
159 * Enqueues admin menu styles.
160 */
161function wpcom_admin_menu_enqueue_styles() {
162    wp_enqueue_style(
163        'wpcom-admin-menu',
164        plugins_url( 'wpcom-admin-menu.css', __FILE__ ),
165        array(),
166        filemtime( __DIR__ . '/wpcom-admin-menu.css' )
167    );
168}
169add_action( 'admin_enqueue_scripts', 'wpcom_admin_menu_enqueue_styles' );
170
171/**
172 * Adds an Upgrades menu.
173 *
174 * This centralizes the Upgrades menu registration for all admin interfaces (Calypso and wp-admin).
175 * The masterbar classes defer to this function instead of registering their own Upgrades menu.
176 */
177function wpcom_add_upgrades_menu() {
178    // Don't show Upgrades on staging sites.
179    if ( get_option( 'wpcom_is_staging_site' ) ) {
180        return;
181    }
182
183    $domain      = wp_parse_url( home_url(), PHP_URL_HOST );
184    $parent_slug = 'paid-upgrades.php';
185
186    // Build the menu title, optionally with the plan label.
187    $plan = wpcom_get_current_plan_name();
188    if ( $plan ) {
189        // Add display:none as a default for cases when CSS is not loaded.
190        // Calypso and the masterbar CSS override this to show the plan label.
191        $menu_title = sprintf(
192            '%1$s<span class="inline-text" style="display:none">%2$s</span>',
193            __( 'Upgrades', 'jetpack-mu-wpcom' ),
194            esc_html( $plan )
195        );
196    } else {
197        $menu_title = __( 'Upgrades', 'jetpack-mu-wpcom' );
198    }
199
200    add_menu_page(
201        __( 'Upgrades', 'jetpack-mu-wpcom' ),
202        $menu_title,
203        'manage_options',
204        $parent_slug,
205        null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
206        'dashicons-cart',
207        2.99
208    );
209
210    add_submenu_page(
211        $parent_slug,
212        __( 'Plans', 'jetpack-mu-wpcom' ),
213        __( 'Plans', 'jetpack-mu-wpcom' ),
214        'manage_options',
215        esc_url( "https://wordpress.com/plans/$domain" ),
216        null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
217        1
218    );
219
220    add_submenu_page(
221        $parent_slug,
222        __( 'Add-ons', 'jetpack-mu-wpcom' ),
223        __( 'Add-ons', 'jetpack-mu-wpcom' ),
224        'manage_options',
225        esc_url( "https://wordpress.com/add-ons/$domain" ),
226        null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
227        2
228    );
229
230    add_submenu_page(
231        $parent_slug,
232        __( 'Domains', 'jetpack-mu-wpcom' ),
233        __( 'Domains', 'jetpack-mu-wpcom' ),
234        'manage_options',
235        esc_url( "https://wordpress.com/domains/manage/$domain" ),
236        null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
237        3
238    );
239
240    add_submenu_page(
241        $parent_slug,
242        __( 'Emails', 'jetpack-mu-wpcom' ),
243        __( 'Emails', 'jetpack-mu-wpcom' ),
244        'manage_options',
245        esc_url( "https://wordpress.com/email/$domain" ),
246        null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
247        4
248    );
249
250    add_submenu_page(
251        $parent_slug,
252        __( 'Purchases', 'jetpack-mu-wpcom' ),
253        __( 'Purchases', 'jetpack-mu-wpcom' ),
254        'manage_options',
255        esc_url( "https://wordpress.com/purchases/subscriptions/$domain" ),
256        null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
257        5
258    );
259
260    // By default, WordPress adds a submenu item for the parent menu item, which we don't want.
261    remove_submenu_page( $parent_slug, $parent_slug );
262
263    // Remove legacy Upgrades submenus registered elsewhere on WP.com.
264    remove_submenu_page( $parent_slug, 'premium-themes' );
265    remove_submenu_page( $parent_slug, 'domains' );
266    remove_submenu_page( $parent_slug, 'my-upgrades' );
267    remove_submenu_page( $parent_slug, 'billing-history' );
268}
269add_action( 'admin_menu', 'wpcom_add_upgrades_menu', 140 ); // After hookpress hook at 130, needed to ensure the legacy submenus are removed.
270
271/**
272 * Gets the current plan's short name.
273 *
274 * @return string|null The plan name, or null if unavailable.
275 */
276function wpcom_get_current_plan_name() {
277    // Simple sites: use WPCOM_Store_API.
278    if ( class_exists( 'WPCOM_Store_API' ) ) {
279        $current_plan = \WPCOM_Store_API::get_current_plan( get_current_blog_id() );
280        if ( ! empty( $current_plan['product_name_short'] ) ) {
281            return $current_plan['product_name_short'];
282        }
283    }
284
285    // Atomic sites: use Jetpack Current_Plan.
286    if ( class_exists( 'Automattic\Jetpack\Current_Plan' ) ) {
287        $products = \Automattic\Jetpack\Current_Plan::get();
288        if ( array_key_exists( 'product_name_short', $products ) ) {
289            return $products['product_name_short'];
290        }
291    }
292
293    return null;
294}
295
296/**
297 * Re-order the submenu items of the given menu slug according to a sorted array of submenu slugs.
298 *
299 * @param string $menu_slug The menu slug.
300 * @param array  $desired_order A list of the submenu slugs in the desired order.
301 */
302function wpcom_reorder_submenu( $menu_slug, $desired_order ) {
303    // Re-order menu.
304    global $submenu;
305    if ( ! isset( $submenu[ $menu_slug ] ) ) {
306        return;
307    }
308
309    $domain          = wp_parse_url( home_url(), PHP_URL_HOST );
310    $ordered_submenu = array();
311
312    // Re-add submenu items in the desired order. Dedupe because a slug in
313    // $desired_order can be a substring of another item's URL, which would
314    // otherwise match the same item twice.
315    foreach ( $desired_order as $submenu_slug ) {
316        foreach ( $submenu[ $menu_slug ] as $item ) {
317            $clean_url = str_replace( $domain, '', $item[2] );
318            if ( str_contains( $clean_url, $submenu_slug ) && ! in_array( $item, $ordered_submenu, true ) ) {
319                $ordered_submenu[] = $item;
320            }
321        }
322    }
323
324    // Add any remaining submenu items.
325    foreach ( $submenu[ $menu_slug ] as $item ) {
326        if ( ! in_array( $item, $ordered_submenu, true ) ) {
327            $ordered_submenu[] = $item;
328        }
329    }
330
331    // phpcs:ignore WordPress.WP.GlobalVariablesOverride
332    $submenu[ $menu_slug ] = $ordered_submenu;
333}
334
335/**
336 * Whether the host replaces My Jetpack with Stats.
337 *
338 * @return bool
339 */
340function jetpack_mu_wpcom_redirects_my_jetpack_to_stats() {
341    if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || ! class_exists( '\\Automattic\\Jetpack\\Current_Plan' ) ) {
342        return false;
343    }
344    $plan = \Automattic\Jetpack\Current_Plan::get();
345    return in_array( $plan['class'] ?? '', array( 'personal', 'premium' ), true );
346}
347add_filter(
348    'jetpack_my_jetpack_modules_management_available',
349    static function ( $available ) {
350        return $available && ! jetpack_mu_wpcom_redirects_my_jetpack_to_stats();
351    }
352);
353
354/**
355 * Whether the Jetpack menu has a My Jetpack entry, whichever page registered it.
356 *
357 * @return bool
358 */
359function wpcom_jetpack_menu_has_my_jetpack() {
360    global $submenu;
361    return in_array( 'my-jetpack', array_column( $submenu['jetpack'] ?? array(), 2 ), true );
362}
363
364/**
365 * Whether Jetpack > Backup opens the wp-admin Backup page rather than Calypso.
366 *
367 * Only meaningful once whatever serves the page has registered it; see WPCOM_WP_Build_Page::REGISTER_PRIORITY.
368 *
369 * @return bool
370 */
371function wpcom_backup_opens_in_wp_admin() {
372    return class_exists( WPCOM_Backup::class ) && WPCOM_Backup::slug_is_claimed() && WPCOM_Backup::is_modernized();
373}
374
375/**
376 * Adds WordPress.com submenu items related to Jetpack under the Jetpack admin menu.
377 */
378function wpcom_add_jetpack_submenu() {
379    $is_simple_site = defined( 'IS_WPCOM' ) && IS_WPCOM;
380
381    $blog_id = Connection_Manager::get_site_id();
382    if ( is_wp_error( $blog_id ) ) {
383        return;
384    }
385
386    $domain = wp_parse_url( home_url(), PHP_URL_HOST );
387
388    // @codeCoverageIgnoreStart
389    // Hide certain Jetpack submenus for Atomic sites on Personal or Premium plans.
390    if ( jetpack_mu_wpcom_redirects_my_jetpack_to_stats() ) {
391        // Jetpack > My Jetpack.
392        wpcom_hide_submenu_page( 'jetpack', 'my-jetpack' );
393
394        // Jetpack > Settings; WoA can pair this with a Jetpack serving either address.
395        wpcom_hide_submenu_page( 'jetpack', admin_url( 'admin.php?page=jetpack#/settings' ) );
396        wpcom_hide_submenu_page( 'jetpack', 'jetpack-settings' );
397
398        // Redirect My Jetpack page to Stats for Atomic sites on Personal or Premium plans.
399        add_action(
400            'admin_init',
401            function () {
402                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No action taken, just checking page.
403                if ( isset( $_GET['page'] ) && 'my-jetpack' === $_GET['page'] ) {
404                    /** This filter is documented in projects/packages/stats-admin/src/class-admin-bar.php */
405                    wp_safe_redirect( apply_filters( 'jetpack_stats_url', admin_url( 'admin.php?page=stats' ), array( 'view' => 'dashboard' ) ) );
406                    exit;
407                }
408            }
409        );
410
411        // Jetpack > Traffic (Calypso).
412        add_submenu_page(
413            'jetpack',
414            esc_attr__( 'Traffic', 'jetpack-mu-wpcom' ),
415            __( 'Traffic', 'jetpack-mu-wpcom' ),
416            'manage_options',
417            'https://wordpress.com/marketing/traffic/' . $domain,
418            null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
419        );
420
421    }
422    // @codeCoverageIgnoreEnd
423
424    // Jetpack > Scan, beside Protect until Protect replaces it.
425    wpcom_hide_submenu_page( 'jetpack', esc_url( Redirect::get_url( 'cloud-scan-history-wp-menu' ) ) );
426    wpcom_hide_submenu_page( 'jetpack', esc_url( Redirect::get_url( 'calypso-scanner' ) ) );
427    add_submenu_page(
428        'jetpack',
429        /** "Scan" is a product name, do not translate. */
430        'Scan',
431        'Scan',
432        'manage_options',
433        'https://wordpress.com/scan/' . $domain,
434        null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
435    );
436
437    // Jetpack > Backup. Until the wp-admin page takes over, Calypso owns the nav and `jetpack-backup`
438    // is hidden rather than removed, so links into it keep working.
439    wpcom_hide_submenu_page( 'jetpack', esc_url( Redirect::get_url( 'calypso-backups' ) ) );
440    if ( ! wpcom_backup_opens_in_wp_admin() ) {
441        wpcom_hide_submenu_page( 'jetpack', 'jetpack-backup' );
442        add_submenu_page(
443            'jetpack',
444            /** "Backup" is a product name, do not translate. */
445            'Backup',
446            'Backup',
447            'manage_options',
448            'https://wordpress.com/backup/' . $domain,
449            null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
450        );
451    }
452
453    // Jetpack > Monetize.
454    add_submenu_page(
455        'jetpack',
456        esc_attr__( 'Monetize', 'jetpack-mu-wpcom' ),
457        __( 'Monetize', 'jetpack-mu-wpcom' ),
458        'manage_options',
459        'https://wordpress.com/earn/' . $domain,
460        null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
461    );
462
463    // Jetpack > Subscribers. Always hide the auto-added Calypso redirect link.
464    wpcom_hide_submenu_page( 'jetpack', esc_url( Redirect::get_url( 'jetpack-menu-jetpack-manage-subscribers', array( 'site' => $blog_id ) ) ) );
465
466    // The unified Newsletter page owns the Subscribers tab on every site, so the legacy
467    // Calypso "Subscribers" submenu is retired. add_filter( 'rsm_jetpack_ui_modernization_newsletter',
468    // '__return_false' ) brings it back â€” the Calypso link only, not a wp-admin dashboard.
469    if ( ! apply_filters( 'rsm_jetpack_ui_modernization_newsletter', true ) ) {
470        add_submenu_page(
471            'jetpack',
472            __( 'Subscribers', 'jetpack-mu-wpcom' ),
473            __( 'Subscribers', 'jetpack-mu-wpcom' ),
474            'manage_options',
475            'https://wordpress.com/subscribers/' . $domain,
476            null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
477        );
478    }
479
480    // Atomic loads Podcast through the Jetpack module, which the owner can switch
481    // off, and this builder runs either way. is_active() is always true on Simple,
482    // where the package loads unconditionally. The package ships with Jetpack, not this one.
483    if ( class_exists( Podcast_Admin_Page::class ) && ( new Modules() )->is_active( 'podcast' ) ) {
484        Podcast_Admin_Page::add_wp_admin_submenu();
485    }
486
487    if ( $is_simple_site ) {
488        // Jetpack > Newsletter.
489        // Register the in-admin Newsletter settings page (with its own render callback
490        // and admin hooks). This must be done here (at priority 999999) because the
491        // Jetpack menu is created by this function and doesn't exist at earlier priorities.
492        if ( class_exists( '\Automattic\Jetpack\Newsletter\Settings' ) ) {
493            // @phan-suppress-next-line PhanUndeclaredClassMethod -- class_exists guarded above; provided by sibling autoloader.
494            $newsletter_settings = new Newsletter_Settings();
495            // @phan-suppress-next-line PhanUndeclaredClassMethod -- class_exists guarded above; provided by sibling autoloader.
496            $newsletter_settings->add_wp_admin_submenu();
497        }
498
499        // Jetpack > VideoPress.
500        // Register the in-admin VideoPress dashboard page. Like Newsletter above, this
501        // must run here (priority 999999) because the Jetpack parent menu is created by
502        // this function and doesn't exist at earlier priorities. Gated on the VideoPress
503        // site feature so it only surfaces for VideoPress-enabled Simple sites during dev.
504        if (
505            class_exists( '\Automattic\Jetpack\VideoPress\Admin_UI' ) &&
506            function_exists( 'wpcom_site_has_feature' ) &&
507            class_exists( '\WPCOM_Features' ) &&
508            wpcom_site_has_feature( \WPCOM_Features::VIDEOPRESS )
509        ) {
510            \Automattic\Jetpack\VideoPress\Admin_UI::add_wp_admin_submenu();
511        }
512
513        // Jetpack > Traffic
514        add_submenu_page(
515            'jetpack',
516            __( 'Traffic', 'jetpack-mu-wpcom' ),
517            __( 'Traffic', 'jetpack-mu-wpcom' ),
518            'manage_options',
519            'https://wordpress.com/marketing/traffic/' . $domain,
520            null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
521        );
522    }
523
524    // Jetpack > Activity Log.
525    // Atomic sites use the native Activity Log page that the `jetpack-activity-log`
526    // package registers at `admin.php?page=jetpack-activity-log`, whichever admin
527    // interface the site uses, and behave like a self-hosted site when that page is
528    // not available: the Calypso Activity Log screen is being retired. Simple sites
529    // get the native page behind a feature flag, and link to wordpress.com/activity-log otherwise.
530    if ( $is_simple_site && ! wpcom_add_native_activity_log_submenu() ) {
531        wpcom_hide_submenu_page( 'jetpack', 'jetpack-activity-log' );
532        add_submenu_page(
533            'jetpack',
534            /** "Activity Log" is a product name, do not translate. */
535            'Activity Log',
536            'Activity Log',
537            'manage_options',
538            'https://wordpress.com/activity-log/' . $domain,
539            null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
540        );
541    }
542
543    wpcom_sort_jetpack_submenu();
544}
545add_action( 'admin_menu', 'wpcom_add_jetpack_submenu', 999999 );
546
547/**
548 * Sorts the Jetpack submenu A to Z by title, with My Jetpack first; Beta Tester, Settings and the
549 * upgrade link go at the bottom, as on self-hosted.
550 *
551 * WordPress points the top-level Jetpack link at the first item, so a `hide-if-js` item sorts after the visible
552 * ones in its tier. Items admin-ui hides only leave on admin_head, so they sort as visible here.
553 */
554function wpcom_sort_jetpack_submenu() {
555    global $submenu;
556    if ( empty( $submenu['jetpack'] ) ) {
557        return;
558    }
559
560    // Admin_Menu::POSITION_* values, as this package doesn't depend on admin-ui. There is no
561    // POSITION_EXTERNAL tier: links that leave wp-admin sort in with everything else.
562    $named_tiers = array(
563        'my-jetpack'                                    => -10,
564        'jetpack_modules'                               => -5,
565        'jetpack-beta'                                  => 998,
566        'jetpack-settings'                              => 998,
567        admin_url( 'admin.php?page=jetpack#/settings' ) => 998,
568    );
569
570    // Without a My Jetpack page, pin Activity Log rather than let the translated A to Z order pick the top link.
571    if ( defined( 'IS_WPCOM' ) && IS_WPCOM && ! wpcom_jetpack_menu_has_my_jetpack() ) {
572        $named_tiers['jetpack-activity-log'] = -5;
573        $named_tiers[ 'https://wordpress.com/activity-log/' . wp_parse_url( home_url(), PHP_URL_HOST ) ] = -5;
574    }
575
576    $keyed = array();
577    foreach ( $submenu['jetpack'] as $item ) {
578        // Drop exact duplicates: Simple can register Akismet twice.
579        if ( in_array( $item, array_column( $keyed, 'item' ), true ) ) {
580            continue;
581        }
582
583        if ( isset( $named_tiers[ $item[2] ] ) ) {
584            $tier = $named_tiers[ $item[2] ];
585        } elseif ( str_contains( $item[2], 'jetpack-wpadmin-sidebar-free-plan-upsell-menu-item' ) ) {
586            // Admin_Menu::UPGRADE_MENU_SLUG, sent through a jetpack.com redirect; admin-ui never adds it on WordPress.com.
587            $tier = 999;
588        } else {
589            $tier = 0;
590        }
591
592        $keyed[] = array(
593            'tier'   => $tier,
594            'hidden' => str_contains( $item[4] ?? '', 'hide-if-js' ),
595            'title'  => wp_strip_all_tags( $item[0] ),
596            'item'   => $item,
597        );
598    }
599
600    usort(
601        $keyed,
602        function ( $a, $b ) {
603            $result = array( $a['tier'], $a['hidden'] ) <=> array( $b['tier'], $b['hidden'] );
604            return 0 !== $result ? $result : strnatcasecmp( $a['title'], $b['title'] );
605        }
606    );
607
608    // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
609    $submenu['jetpack'] = array_column( $keyed, 'item' );
610}
611
612/**
613 * Registers the native Activity Log page under Jetpack on a Simple site.
614 *
615 * @return bool Whether the page was registered.
616 */
617function wpcom_add_native_activity_log_submenu() {
618    if ( ! Feature_Flags::is_enabled( WPCOM_NATIVE_ACTIVITY_LOG_FLAG ) ) {
619        return false;
620    }
621
622    // The package ships with Jetpack, which deploys separately from this one.
623    if ( ! class_exists( '\Automattic\Jetpack\Activity_Log\Jetpack_Activity_Log' ) ) {
624        return false;
625    }
626
627    // A package predating Simple support returns null, which keeps the Calypso link.
628    // @phan-suppress-next-line PhanUndeclaredClassMethod -- class_exists guarded above; provided by sibling autoloader.
629    return null !== \Automattic\Jetpack\Activity_Log\Jetpack_Activity_Log::add_wp_admin_submenu();
630}
631
632/*
633 * Prevents the Jetpack menu from being overridden on Simple sites.
634 *
635 * TODO: After deploying https://github.com/Automattic/jetpack/pull/39393, we can remove the `add_jetpack_submenu` function and this `remove_action` call.
636 */
637remove_action( 'admin_menu', 'add_jetpack_submenu', 999999 );
638
639/**
640 * Ensures customizer menu and admin bar items are not visible on a block theme.
641 */
642function wpcom_hide_customizer_submenu_on_block_theme() {
643    if ( wp_is_block_theme() && ! is_customize_preview() ) {
644        remove_action( 'customize_register', 'add_logotool_button', 20 );
645        remove_action( 'customize_register', 'footercredits_register', 99 );
646        remove_action( 'customize_register', 'wpcom_disable_customizer_site_icon', 20 );
647
648        if ( class_exists( '\Jetpack_Fonts' ) ) {
649            $jetpack_fonts_instance = \Jetpack_Fonts::get_instance();
650            remove_action( 'customize_register', array( $jetpack_fonts_instance, 'register_controls' ) );
651            remove_action( 'customize_register', array( $jetpack_fonts_instance, 'maybe_prepopulate_option' ), 0 );
652        }
653
654        remove_action( 'customize_register', array( 'Jetpack_Fonts_Typekit', 'maybe_override_for_advanced_mode' ), 20 );
655
656        remove_action( 'customize_register', 'Automattic\Jetpack\Masterbar\register_css_nudge_control' );
657
658        remove_action( 'customize_register', array( 'Jetpack_Custom_CSS_Customizer', 'customize_register' ) );
659
660        remove_action( 'customize_register', array( 'Jetpack_Custom_CSS_Enhancements', 'customize_register' ) );
661    }
662}
663
664$customizer_removal_hook = defined( 'REST_API_REQUEST' ) && REST_API_REQUEST ? 'rest_pre_dispatch' : 'init';
665add_action( $customizer_removal_hook, 'wpcom_hide_customizer_submenu_on_block_theme' );
666
667/**
668 * Links were removed in 3.5 core, but we've kept them active on dotcom.
669 *
670 * This function will check to see if Links should be enabled based on the number of links in the database
671 * and then set an option to minimize repeat queries later. The Links menu is visible when the Link Manager is enabled.
672 *
673 * @return void
674 */
675function wpcom_maybe_enable_link_manager() {
676    if ( get_option( 'link_manager_check' ) ) {
677        return;
678    }
679
680    // The max ID number of the auto-generated links.
681    // See /wp-content/mu-plugins/wpcom-wp-install-defaults.php in WP.com.
682    $max_default_id = 10;
683
684    // We are only checking the latest entry link_id so are limiting the query to 1.
685    $link_manager_links = get_bookmarks(
686        array(
687            'orderby'        => 'link_id',
688            'order'          => 'DESC',
689            'limit'          => 1,
690            'hide_invisible' => 0,
691        )
692    );
693
694    $has_links = is_countable( $link_manager_links ) && count( $link_manager_links ) > 0 && $link_manager_links[0]->link_id > $max_default_id;
695
696    update_option( 'link_manager_enabled', intval( $has_links ) );
697    update_option( 'link_manager_check', time() );
698}
699add_action( 'init', 'wpcom_maybe_enable_link_manager' );
700
701/**
702 * Hides a submenu item.
703 *
704 * Useful in cases where we cannot remove a submenu item because there is external logic
705 * that depends on the route registered by that submenu.
706 *
707 * @param string $menu_slug The slug of the parent menu.
708 * @param string $submenu_slug The slug of the submenu that should be hidden.
709 */
710function wpcom_hide_submenu_page( string $menu_slug, string $submenu_slug ) {
711    global $submenu;
712
713    if ( ! isset( $submenu[ $menu_slug ] ) ) {
714        return;
715    }
716
717    foreach ( $submenu[ $menu_slug ] as $i => $item ) {
718        if ( $submenu_slug !== $item[2] ) {
719            continue;
720        }
721
722        $css_hide_class = 'hide-if-js';
723        $css_classes    = empty( $item[4] ) ? $css_hide_class : $item[4] . ' ' . $css_hide_class;
724
725        // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
726        $submenu[ $menu_slug ][ $i ][4] = $css_classes;
727        return;
728    }
729}
730
731/**
732 * Handles the Plugins menu.
733 */
734function wpcom_add_plugins_menu() {
735    global $menu;
736    $is_simple_site          = defined( 'IS_WPCOM' ) && IS_WPCOM;
737    $is_atomic_site          = ! $is_simple_site;
738    $uses_wp_admin_interface = get_option( 'wpcom_admin_interface' ) === 'wp-admin';
739
740    if ( $is_simple_site ) {
741        $has_plugins_menu = false;
742        foreach ( $menu as &$menu_item ) {
743            if ( 'plugins.php' === $menu_item[2] ) {
744                $has_plugins_menu = true;
745                break;
746            }
747        }
748
749        if ( ! $has_plugins_menu ) {
750            // TODO: Remove `remove_menu_page('plugins.php');` from `/wp-content/admin-plugins/wpcom-misc.php`.
751            add_menu_page(
752                __( 'Plugins', 'jetpack-mu-wpcom' ),
753                __( 'Plugins', 'jetpack-mu-wpcom' ),
754                'manage_options', // Roughly means "is a site admin"
755                'plugins.php',
756                null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
757                'dashicons-admin-plugins',
758                65
759            );
760        }
761
762        if ( function_exists( 'wpcom_plugins_display_marketplace' ) ) {
763            add_submenu_page(
764                'plugins.php',
765                __( 'Add Plugin', 'jetpack-mu-wpcom' ),
766                __( 'Add Plugin', 'jetpack-mu-wpcom' ),
767                'manage_options', // Roughly means "is a site admin"
768                'wpcom-install-plugin',
769                'wpcom_plugins_display_marketplace'
770            );
771
772            if ( ! $uses_wp_admin_interface ) {
773                wpcom_hide_submenu_page( 'plugins.php', 'wpcom-install-plugin' );
774            }
775        }
776    }
777
778    $domain = wp_parse_url( home_url(), PHP_URL_HOST );
779    if ( $uses_wp_admin_interface ) {
780        add_submenu_page(
781            'plugins.php',
782            /* translators: Name of the Plugins submenu that links to the Plugins Marketplace */
783                __( 'Marketplace', 'jetpack-mu-wpcom' ),
784            /* translators: Name of the Plugins submenu that links to the Plugins Marketplace */
785                __( 'Marketplace', 'jetpack-mu-wpcom' ),
786            'manage_options', // Roughly means "is a site admin"
787            'https://wordpress.com/plugins/' . $domain,
788            null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
789        );
790    }
791
792    if ( $is_atomic_site ) {
793        if (
794            ! get_option( 'wpcom_is_staging_site' ) &&
795            function_exists( 'wpcom_site_has_feature' ) &&
796            wpcom_site_has_feature( \WPCOM_Features::SCHEDULED_UPDATES )
797        ) {
798            add_submenu_page(
799                'plugins.php',
800                esc_attr__( 'Scheduled Updates', 'jetpack-mu-wpcom' ),
801                __( 'Scheduled Updates', 'jetpack-mu-wpcom' ),
802                'update_plugins',
803                esc_url( "https://wordpress.com/plugins/scheduled-updates/$domain" ),
804                null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal
805            );
806        }
807    }
808}
809add_action( 'admin_menu', 'wpcom_add_plugins_menu' );
810
811/**
812 * Adds some Tools menus that are missing on Simple sites.
813 */
814function wpcom_add_tools_menu() {
815    $is_simple_site = defined( 'IS_WPCOM' ) && IS_WPCOM;
816    if ( $is_simple_site ) {
817        add_submenu_page(
818            'tools.php',
819            __( 'Export Personal Data', 'jetpack-mu-wpcom' ),
820            __( 'Export Personal Data', 'jetpack-mu-wpcom' ),
821            'manage_options',
822            'wpcom-export-personal-data',
823            'wpcom_display_export_erase_personal_data_page'
824        );
825
826        add_submenu_page(
827            'tools.php',
828            __( 'Erase Personal Data', 'jetpack-mu-wpcom' ),
829            __( 'Erase Personal Data', 'jetpack-mu-wpcom' ),
830            'manage_options',
831            'wpcom-erase-personal-data',
832            'wpcom_display_export_erase_personal_data_page'
833        );
834
835        add_submenu_page(
836            'tools.php',
837            __( 'Site Health', 'jetpack-mu-wpcom' ),
838            __( 'Site Health', 'jetpack-mu-wpcom' ),
839            'manage_options',
840            'wpcom-site-health',
841            'wpcom_display_site_health_page'
842        );
843    }
844
845    wpcom_reorder_submenu(
846        'tools.php',
847        array(
848            'tools.php',
849            'advertising-moved',
850            'monetize',
851            'import',
852            'export.php',
853            'export-media-files',
854            'site-health',
855            'export-personal-data',
856            'erase-personal-data',
857            'theme-editor',
858            'plugin-editor',
859        )
860    );
861}
862add_action( 'admin_menu', 'wpcom_add_tools_menu', 999999 );
863
864/**
865 * Sends the retired Tools > Marketing URL to the dashboard.
866 *
867 * The page was removed in DOTCOM-18531. Nothing links to it any more, but the
868 * Calypso sidebar pointed at this exact URL until the removal shipped, so it is
869 * still in plenty of browser histories and bookmarks. Left alone, WordPress
870 * answers an unregistered page slug with a bare "Cannot load
871 * wpcom-marketing-tools.", which reads like a broken install rather than a page
872 * that went away. The dashboard mirrors where the Calypso URL now lands.
873 *
874 * This runs on admin_init, which fires before the point in wp-admin/admin.php
875 * where core gives up on the slug.
876 *
877 * Tombstone: delete this function and its hook by 2027-09-10. A year is long
878 * enough for the stale bookmarks to age out, and after that the redirect is
879 * just a puzzle for whoever reads this next.
880 */
881function wpcom_redirect_retired_marketing_page() {
882    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading a menu slug from a GET request, no state changes.
883    if ( ! isset( $_GET['page'] ) ) {
884        return;
885    }
886
887    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- As above.
888    if ( 'wpcom-marketing-tools' !== sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) {
889        return;
890    }
891
892    wp_safe_redirect( admin_url() );
893    exit; // @codeCoverageIgnore -- the tests unwind from the wp_redirect filter before this line.
894}
895add_action( 'admin_init', 'wpcom_redirect_retired_marketing_page' ); // @codeCoverageIgnore
896
897/**
898 * Displays an Export/Erase Personal Date page for Simple sites.
899 */
900function wpcom_display_export_erase_personal_data_page() {
901    if ( str_contains( get_current_screen()->id, 'export-personal-data' ) ) {
902        $page_title = __( 'Export Personal Data', 'jetpack-mu-wpcom' );
903    } else {
904        $page_title = __( 'Erase Personal Data', 'jetpack-mu-wpcom' );
905    }
906
907    wpcom_display_callout(
908        'dashicons-id-alt',
909        $page_title,
910        array(
911            __( 'WordPress.com gives you the tools to manage personal data from your dashboard, like viewing or deleting comments tied to a visitor.', 'jetpack-mu-wpcom' ),
912            __( 'To support privacy requests, make sure people can reach you easily, whether through a contact form or an email in your Privacy Policy.', 'jetpack-mu-wpcom' ),
913        ),
914        localized_wpcom_url( 'https://wordpress.com/support/your-site-and-the-gdpr/' ),
915        __( 'Learn more', 'jetpack-mu-wpcom' ),
916        plugins_url( 'images/performance.svg', __FILE__ )
917    );
918}
919
920/**
921 * Displays a callout on Simple Sites for Tools > Site Health menu.
922 *
923 * @return void
924 */
925function wpcom_display_site_health_page() {
926    wpcom_display_callout(
927        'dashicons-admin-site-alt3',
928        __( 'Your site\'s in good hands', 'jetpack-mu-wpcom' ),
929        array(
930            __( 'No need to stress over performance or security checks, WordPress.com handles that for you behind the scenes.', 'jetpack-mu-wpcom' ),
931            __( 'That way, your site stays fast, safe, and reliable, without any extra effort from you.', 'jetpack-mu-wpcom' ),
932        ),
933        localized_wpcom_url( 'https://wordpress.com/support/choose-a-host/#frequently-asked-questions-about-managed-hosting-with-word-press-com' ),
934        __( 'Learn more', 'jetpack-mu-wpcom' ),
935        plugins_url( 'images/cloud.svg', __FILE__ )
936    );
937}
938
939/**
940 * Adjust the Settings submenus so they are sorted consistently.
941 */
942function wpcom_add_settings_menu() {
943    wpcom_reorder_submenu(
944        'options-general.php',
945        array(
946            'general',
947            'writing',
948            'reading',
949            'discussion',
950            'media',
951            'permalink',
952            'privacy',
953            'sharing',
954            'optimize',
955            'crowdsignal',
956            'rating',
957            'newsletter',
958        )
959    );
960}
961add_action( 'admin_menu', 'wpcom_add_settings_menu', 999999 );
962
963if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
964    require_once __DIR__ . '/p2-admin-menu.php';
965}