PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-admin-ui / src / class-admin-menu.php

class-admin-menu.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at jetpack_vendor/automattic/jetpack-admin-ui/src/class-admin-menu.php

879 lines 28.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin Menu Registration
4 *
5 * @package automattic/jetpack-admin-ui
6 */
7
8 namespace Automattic\Jetpack\Admin_UI;
9
10 use Automattic\Jetpack\Tracking;
11 use Jetpack_Options;
12 use Jetpack_Tracks_Client;
13
14 /**
15 * This class offers a wrapper to add_submenu_page and makes sure stand-alone plugin's menu items are always added under the Jetpack top level menu.
16 * If the Jetpack top level was not previously registered by other plugin, it will be registered here.
17 */
18 class Admin_Menu {
19
20 const PACKAGE_VERSION = '0.12.0';
21
22 /**
23 * Slug used for the upgrade menu item and redirect URL.
24 *
25 * Keep the slug in sync with `$upgrade-menu-slug` at admin-ui-upgrade-menu.scss
26 *
27 * @var string
28 */
29 const UPGRADE_MENU_SLUG = 'jetpack-wpadmin-sidebar-free-plan-upsell-menu-item';
30
31 /**
32 * Fallback upgrade URL when the Redirect class is unavailable.
33 *
34 * @var string
35 */
36 const UPGRADE_MENU_FALLBACK_URL = 'https://jetpack.com/upgrade/';
37
38 /*
39 * The sidebar's position tiers. Items sharing a tier sort alphabetically by menu title, so a
40 * product should pass no position and land in POSITION_DEFAULT. Reach for another tier only
41 * for one of the roles below; an int of your own silently opts the item out of that order.
42 */
43
44 /**
45 * Owns the top-level Jetpack link, since WordPress points it at whichever item sorts first.
46 *
47 * @var int
48 */
49 const POSITION_FIRST = -10;
50
51 /**
52 * Takes the first slot when nothing claims POSITION_FIRST, as in offline mode.
53 *
54 * @var int
55 */
56 const POSITION_FIRST_FALLBACK = -5;
57
58 /**
59 * Products, in alphabetical order. Pass no position rather than this.
60 *
61 * @var int
62 */
63 const POSITION_DEFAULT = 0;
64
65 /**
66 * Links that leave wp-admin, grouped below the products.
67 *
68 * @var int
69 */
70 const POSITION_EXTERNAL = 100;
71
72 /**
73 * Site-level items that belong under everything else.
74 *
75 * @var int
76 */
77 const POSITION_LAST = 998;
78
79 /**
80 * The upgrade item this package adds, below every tier a caller can use.
81 *
82 * @var int
83 */
84 const POSITION_UPGRADE = 999;
85
86 /**
87 * Handle for the shared, token-only WPDS design-tokens stylesheet.
88 *
89 * Registered once and enqueued on every Jetpack admin page so that
90 * `var(--wpds-*)` values resolve at runtime instead of falling back to
91 * their hand-written hex defaults.
92 *
93 * @var string
94 */
95 const DESIGN_TOKENS_HANDLE = 'jetpack-admin-ui-design-tokens';
96
97 /**
98 * Handle for the source-less stylesheet that hides WordPress core admin notices.
99 *
100 * @var string
101 */
102 const HIDE_CORE_NOTICES_HANDLE = 'jetpack-admin-ui-hide-core-notices';
103
104 /**
105 * Visibility state: show the item only when its declared gate is satisfied.
106 *
107 * @var string
108 */
109 const VISIBILITY_DEFAULT = 'default';
110
111 /**
112 * Visibility state: show the item whatever its gate says.
113 *
114 * @var string
115 */
116 const VISIBILITY_VISIBLE = 'visible';
117
118 /**
119 * Visibility state: keep the item out whatever its gate says.
120 *
121 * @var string
122 */
123 const VISIBILITY_HIDDEN = 'hidden';
124
125 /**
126 * Whether this class has been initialized
127 *
128 * @var boolean
129 */
130 private static $initialized = false;
131
132 /**
133 * List of menu items enqueued to be added
134 *
135 * @var array
136 */
137 private static $menu_items = array();
138
139 /**
140 * Hook suffixes of the pages registered through this class.
141 *
142 * Used to scope the design-tokens stylesheet to Jetpack admin pages.
143 *
144 * @var array
145 */
146 private static $page_hooks = array();
147
148 /**
149 * Optional connection manager dependency.
150 *
151 * @var object|null
152 */
153 private static $connection_manager = null;
154
155 /**
156 * Callback that answers whether a menu item's declared gate is satisfied.
157 *
158 * Set by My Jetpack, which owns the product classes the gates name. Stays null on a
159 * site without it, where every gate then fails open.
160 *
161 * @var callable|null
162 */
163 private static $visibility_resolver = null;
164
165 /**
166 * Menu slugs registered this request but kept out of the rendered sidebar.
167 *
168 * @var string[]
169 */
170 private static $hidden_menu_slugs = array();
171
172 /**
173 * Initialize the class and set up the main hook
174 *
175 * @return void
176 */
177 public static function init() {
178 if ( ! self::$initialized ) {
179 self::$initialized = true;
180 self::handle_akismet_menu();
181 add_action( 'admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
182 add_action( 'network_admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
183 add_action( 'admin_head', array( __CLASS__, 'remove_hidden_menu_items' ) );
184 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'add_upgrade_menu_item_styles' ) );
185 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_design_tokens' ) );
186 }
187 }
188
189 /**
190 * Handles the Akismet menu item when used alongside other stand-alone plugins
191 *
192 * When Jetpack plugin is present, Akismet menu item is moved under the Jetpack top level menu, but if Akismet is active alongside other stand-alone plugins,
193 * we use this method to move the menu item.
194 */
195 private static function handle_akismet_menu() {
196 if ( class_exists( 'Akismet_Admin' ) ) {
197 add_action(
198 'admin_menu',
199 function () {
200 // Prevent Akismet from adding a menu item.
201 remove_action( 'admin_menu', array( 'Akismet_Admin', 'admin_menu' ), 5 );
202
203 // Add an Anti-spam menu item for Jetpack.
204 self::add_menu( __( 'Akismet Anti-spam', 'jetpack-admin-ui' ), __( 'Akismet Anti-spam', 'jetpack-admin-ui' ), 'manage_options', 'akismet-key-config', array( 'Akismet_Admin', 'display_page' ) );
205 },
206 4
207 );
208
209 }
210 }
211
212 /**
213 * Callback to the admin_menu and network_admin_menu hooks that will register the enqueued menu items
214 *
215 * @return void
216 */
217 public static function admin_menu_hook_callback() {
218 $can_see_toplevel_menu = true;
219 $jetpack_plugin_present = class_exists( 'Jetpack_React_Page' );
220 $icon = 'dashicons-admin-plugins';
221 if ( method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_admin_menu_logo' ) ) {
222 $icon = ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_admin_menu_logo();
223 } elseif ( method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_logo' ) ) {
224 $icon = ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_logo();
225 }
226
227 if ( ! $jetpack_plugin_present ) {
228 add_menu_page(
229 'Jetpack',
230 'Jetpack',
231 'edit_posts',
232 'jetpack',
233 '__return_null',
234 $icon,
235 3
236 );
237
238 // If Jetpack plugin is not present, user will only be able to see this menu if they have enough capability to at least one of the sub menus being added.
239 $can_see_toplevel_menu = false;
240 }
241
242 /**
243 * The add_sub_menu function has a bug and will not keep the right order of menu items.
244 *
245 * @see https://core.trac.wordpress.org/ticket/52035
246 * Let's order the items before registering them.
247 * Since this all happens after the Jetpack plugin menu items were added, all items will be added after Jetpack plugin items - unless position is very low number (smaller than the number of menu items present in Jetpack plugin).
248 */
249 usort(
250 self::$menu_items,
251 function ( $a, $b ) {
252 $position_a = empty( $a['position'] ) ? 0 : $a['position'];
253 $position_b = empty( $b['position'] ) ? 0 : $b['position'];
254 $result = $position_a <=> $position_b;
255
256 if ( 0 === $result ) {
257 // Case-insensitive and number-aware, so "eCommerce" sorts with the Es.
258 // Still a byte compare: a leading accented character sorts after Z.
259 $result = strnatcasecmp( $a['menu_title'], $b['menu_title'] );
260 }
261
262 return $result;
263 }
264 );
265
266 $visibility = self::get_visibility_states();
267
268 self::$hidden_menu_slugs = array();
269
270 foreach ( self::$menu_items as $menu_item ) {
271 if ( ! current_user_can( $menu_item['capability'] ) ) {
272 continue;
273 }
274
275 /*
276 * A hidden item is still registered, so its page keeps resolving for links into it.
277 * It leaves the submenu on admin_head instead: core's access check reads $submenu.
278 */
279 if ( self::is_menu_item_visible( $menu_item, $visibility ) ) {
280 $can_see_toplevel_menu = true;
281 } else {
282 self::$hidden_menu_slugs[] = $menu_item['menu_slug'];
283 }
284
285 add_submenu_page(
286 'jetpack',
287 $menu_item['page_title'],
288 $menu_item['menu_title'],
289 $menu_item['capability'],
290 $menu_item['menu_slug'],
291 $menu_item['function'],
292 $menu_item['position']
293 );
294 }
295
296 if ( ! $jetpack_plugin_present ) {
297 remove_submenu_page( 'jetpack', 'jetpack' );
298 }
299
300 if ( ! $can_see_toplevel_menu ) {
301 remove_menu_page( 'jetpack' );
302 }
303
304 self::maybe_add_upgrade_menu_item();
305 }
306
307 /**
308 * Adds a new submenu to the Jetpack Top level menu
309 *
310 * The parameters this method accepts are the same as @see add_submenu_page. This class will
311 * aggreagate all menu items registered by stand-alone plugins and make sure they all go under the same
312 * Jetpack top level menu. It will also handle the top level menu registration in case the Jetpack plugin is not present.
313 *
314 * @param string $page_title The text to be displayed in the title tags of the page when the menu
315 * is selected.
316 * @param string $menu_title The text to be used for the menu.
317 * @param string $capability The capability required for this menu to be displayed to the user.
318 * @param string $menu_slug The slug name to refer to this menu by. Should be unique for this menu
319 * and only include lowercase alphanumeric, dashes, and underscores characters
320 * to be compatible with sanitize_key().
321 * @param callable|null $function The function to be called to output the content for this page.
322 * @param int|null $position The position in the menu order this item should appear. Leave empty typically.
323 * @param array $args Optional. Visibility declaration for this item:
324 * - 'product' (string) My Jetpack product slug whose activation gates the item.
325 * - 'module' (string) Jetpack module name, for items with no product class.
326 * - 'key' (string) The name hosts use for this item in the visibility
327 * filter. Declare one on every item; see get_item_key().
328 * An item that declares no gate is always shown.
329 *
330 * @return string The resulting page's hook_suffix
331 */
332 public static function add_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $position = null, $args = array() ) {
333 self::init();
334 self::$menu_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'position', 'args' );
335
336 /**
337 * Let's return the page hook so consumers can use.
338 * We know all pages will be under Jetpack top level menu page, so we can hardcode the first part of the string.
339 * Using get_plugin_page_hookname here won't work because the top level page is not registered yet.
340 */
341 $hook = 'jetpack_page_' . $menu_slug;
342
343 // Track the page hook so the design-tokens stylesheet can be scoped to it.
344 self::$page_hooks[] = $hook;
345
346 // Hide WordPress core admin notices on this Jetpack page. The load-<hook>
347 // action only fires when the matching screen is being rendered, so this
348 // stays scoped to Jetpack pages and reaches every page registered here.
349 add_action( 'load-' . $hook, array( __CLASS__, 'hide_core_admin_notices' ) );
350 add_action( 'load-' . $hook . '-network', array( __CLASS__, 'hide_core_admin_notices' ) );
351
352 return $hook;
353 }
354
355 /**
356 * Enqueues the stylesheet that hides WordPress core admin notices on the current Jetpack page.
357 *
358 * Hooked from the page's load-<hook> action so it only runs on Jetpack screens. That action
359 * runs before admin_enqueue_scripts, so the handle is queued in time to be printed.
360 *
361 * @return void
362 */
363 public static function hide_core_admin_notices() {
364 // wp_add_inline_style() appends, so the CSS is attached only while the handle is new to the request.
365 if ( ! wp_style_is( self::HIDE_CORE_NOTICES_HANDLE, 'registered' ) ) {
366 wp_register_style( self::HIDE_CORE_NOTICES_HANDLE, false, array(), self::PACKAGE_VERSION );
367 wp_add_inline_style( self::HIDE_CORE_NOTICES_HANDLE, self::get_hide_core_admin_notices_styles() );
368 }
369
370 wp_enqueue_style( self::HIDE_CORE_NOTICES_HANDLE );
371 }
372
373 /**
374 * Enqueues the CSS that hides WordPress core admin notices.
375 *
376 * Callers must run this before WordPress flushes the style queue in
377 * print_admin_styles() (admin_print_styles, priority 20). Later than that,
378 * the handle is never printed. The previous admin_print_styles priority-10
379 * hook still works.
380 *
381 * @deprecated 0.10.0 Use hide_core_admin_notices(), which enqueues the CSS.
382 *
383 * @return void
384 */
385 public static function print_hide_core_admin_notices_style() {
386 _deprecated_function( __METHOD__, 'admin-ui-0.10.0', __CLASS__ . '::hide_core_admin_notices' );
387 self::hide_core_admin_notices();
388 }
389
390 /**
391 * Gets the CSS that hides WordPress core admin notices.
392 *
393 * We only target direct children of #wpbody-content (where core renders notices via the
394 * admin_notices / all_admin_notices hooks). This intentionally leaves JITMs untouched —
395 * they output `.jetpack-jitm-message`, not `.notice` — and leaves in-app/React notices
396 * untouched, since those render deeper inside `.wrap`. The CSS rides on a source-less
397 * handle rather than a build asset so it also reaches older Jetpack pages that ship no
398 * stylesheet of their own.
399 *
400 * @return string CSS rules.
401 */
402 private static function get_hide_core_admin_notices_styles() {
403 return '
404 #wpbody-content > .notice,
405 #wpbody-content > .update-nag,
406 #wpbody-content > .updated,
407 #wpbody-content > .error { display: none !important; }
408 ';
409 }
410
411 /**
412 * Sets the callback that resolves a menu item's declared gate.
413 *
414 * The callback receives the item's $args array and returns true (gate satisfied),
415 * false (not satisfied), or null when it cannot answer — an unknown product slug,
416 * for instance. Null is treated as satisfied, so a gate this package cannot resolve
417 * never removes a menu item.
418 *
419 * This is the seam My Jetpack fills. Hosts wanting to shape the sidebar should use the
420 * `jetpack_admin_menu_visibility` filter instead, which takes precedence: an item the
421 * filter names is never put to this callback at all.
422 *
423 * @param callable|null $resolver Resolver callback, or null to clear it.
424 * @return void
425 */
426 public static function set_visibility_resolver( $resolver ) {
427 self::$visibility_resolver = $resolver;
428 }
429
430 /**
431 * Takes hidden items out of the Jetpack submenu before the sidebar renders.
432 *
433 * Runs on admin_head, after core's access check has already resolved the current page
434 * against $submenu, so a hidden item's page stays reachable while its entry disappears.
435 *
436 * @return void
437 */
438 public static function remove_hidden_menu_items() {
439 foreach ( self::$hidden_menu_slugs as $menu_slug ) {
440 remove_submenu_page( 'jetpack', plugin_basename( $menu_slug ) );
441 }
442 }
443
444 /**
445 * Returns the name a host uses for a menu item in the visibility filter.
446 *
447 * The menu slug is only a fallback. It is the wrong thing to hand a host as an identifier:
448 * several items register a URL as their slug, Blaze's is filterable, and VideoPress swaps
449 * between two slugs depending on whether the module is active — so a host naming one of
450 * them is naming a moving target, or only half an item.
451 *
452 * @param array $menu_item A registered menu item.
453 * @return string
454 */
455 private static function get_item_key( array $menu_item ) {
456 if ( ! empty( $menu_item['args']['key'] ) ) {
457 return (string) $menu_item['args']['key'];
458 }
459
460 return (string) $menu_item['menu_slug'];
461 }
462
463 /**
464 * Builds the item => state map and hands it to hosts to amend.
465 *
466 * @return array Map of item key to one of the VISIBILITY_* states.
467 */
468 private static function get_visibility_states() {
469 $states = array();
470
471 foreach ( self::$menu_items as $menu_item ) {
472 $states[ self::get_item_key( $menu_item ) ] = self::VISIBILITY_DEFAULT;
473 }
474
475 /**
476 * Filters which Jetpack items appear in the wp-admin sidebar.
477 *
478 * Governs the sidebar entry only — a hidden item's page stays reachable by URL, so this
479 * is not an access control. States: 'default' follows the item's feature, 'visible' shows it, 'hidden' removes it.
480 *
481 * @since 0.12.0
482 *
483 * @param array $states Map of item key (menu slug unless the item declared one) to state.
484 * @param array $menu_items The registered menu items, for context.
485 */
486 $states = apply_filters( 'jetpack_admin_menu_visibility', $states, self::$menu_items );
487
488 return is_array( $states ) ? $states : array();
489 }
490
491 /**
492 * Decides whether a single menu item should appear in the sidebar.
493 *
494 * @param array $menu_item A registered menu item.
495 * @param array $visibility The resolved state map from get_visibility_states().
496 * @return bool
497 */
498 private static function is_menu_item_visible( array $menu_item, array $visibility ) {
499 $key = self::get_item_key( $menu_item );
500 $state = $visibility[ $key ] ?? self::VISIBILITY_DEFAULT;
501
502 if ( self::VISIBILITY_HIDDEN === $state ) {
503 return false;
504 }
505
506 if ( self::VISIBILITY_VISIBLE === $state ) {
507 return true;
508 }
509
510 return self::is_gate_satisfied( $menu_item['args'] ?? array() );
511 }
512
513 /**
514 * Asks the resolver whether an item's declared gate is satisfied.
515 *
516 * Everything here fails open. An item that declares no gate, a site with no resolver
517 * registered, and a gate the resolver does not recognize all keep the item in the
518 * sidebar, so adopting this mechanism cannot remove an item nobody asked it to.
519 *
520 * @param array $args The item's visibility declaration.
521 * @return bool
522 */
523 private static function is_gate_satisfied( array $args ) {
524 if ( ! isset( $args['product'] ) && ! isset( $args['module'] ) ) {
525 return true;
526 }
527
528 if ( ! is_callable( self::$visibility_resolver ) ) {
529 return true;
530 }
531
532 $resolved = call_user_func( self::$visibility_resolver, $args );
533
534 return null === $resolved ? true : (bool) $resolved;
535 }
536
537 /**
538 * Removes an already added submenu
539 *
540 * @param string $menu_slug The slug of the submenu to remove.
541 *
542 * @return array|false The removed submenu on success, false if not found.
543 */
544 public static function remove_menu( $menu_slug ) {
545
546 foreach ( self::$menu_items as $index => $menu_item ) {
547 if ( $menu_item['menu_slug'] === $menu_slug ) {
548 unset( self::$menu_items[ $index ] );
549
550 return $menu_item;
551 }
552 }
553
554 return false;
555 }
556
557 /**
558 * Gets the slug for the first item under the Jetpack top level menu
559 *
560 * Skips hidden items rather than reading $submenu alone, because callers on admin_enqueue_scripts
561 * and outside wp-admin ask before — or without — the admin_head pass that drops them.
562 *
563 * @return string|null
564 */
565 public static function get_top_level_menu_item_slug() {
566 global $submenu;
567
568 if ( empty( $submenu['jetpack'] ) ) {
569 return null;
570 }
571
572 $hidden = array_map( 'plugin_basename', self::$hidden_menu_slugs );
573
574 foreach ( $submenu['jetpack'] as $item ) {
575 if ( isset( $item[2] ) && ! in_array( $item[2], $hidden, true ) ) {
576 return $item[2];
577 }
578 }
579
580 return null;
581 }
582
583 /**
584 * Gets the URL for the first item under the Jetpack top level menu
585 *
586 * @param string $fallback If Jetpack menu is not there or no children is found, return this fallback instead. Default to admin_url().
587 * @return string
588 */
589 public static function get_top_level_menu_item_url( $fallback = false ) {
590 $slug = self::get_top_level_menu_item_slug();
591
592 if ( $slug ) {
593 $url = menu_page_url( $slug, false );
594 return $url;
595 }
596
597 $url = $fallback ? $fallback : admin_url();
598 return $url;
599 }
600
601 /**
602 * Checks whether the current site should show the upgrade menu item.
603 *
604 * The upgrade menu is only shown to administrators on free-plan sites
605 * that are not hosted on WordPress.com.
606 *
607 * @return bool True if the upgrade menu should be shown.
608 */
609 private static function should_show_upgrade_menu() {
610
611 // Only show to administrators.
612 if ( ! current_user_can( 'manage_options' ) ) {
613 return false;
614 }
615
616 // Don't show upsells on WordPress.com platform.
617 if ( class_exists( '\Automattic\Jetpack\Status\Host' ) ) {
618 $host = new \Automattic\Jetpack\Status\Host();
619 if ( $host->is_wpcom_platform() ) {
620 return false;
621 }
622 }
623
624 // Don't show upsells in offline/development mode.
625 if ( class_exists( '\Automattic\Jetpack\Status' ) ) {
626 $status = new \Automattic\Jetpack\Status();
627 if ( $status->is_offline_mode() ) {
628 return false;
629 }
630 }
631
632 // Only show after the site and current user are connected.
633 if ( ! self::is_site_and_user_connected() ) {
634 return false;
635 }
636
637 // Only show to free-plan sites.
638 return self::is_free_plan();
639 }
640
641 /**
642 * Checks whether the site and current user are connected to WordPress.com.
643 *
644 * @return bool True if site and current user are connected.
645 */
646 private static function is_site_and_user_connected() {
647 $connection_manager = self::$connection_manager;
648 if ( ! $connection_manager && class_exists( '\Automattic\Jetpack\Connection\Manager' ) ) {
649 $connection_manager = new \Automattic\Jetpack\Connection\Manager();
650 self::$connection_manager = $connection_manager;
651 }
652
653 if (
654 $connection_manager
655 && is_callable( array( $connection_manager, 'is_connected' ) )
656 && is_callable( array( $connection_manager, 'is_user_connected' ) )
657 ) {
658 return (bool) $connection_manager->is_connected()
659 && (bool) $connection_manager->is_user_connected( get_current_user_id() );
660 }
661
662 return false;
663 }
664
665 /**
666 * Sets the connection manager dependency; used by tests.
667 *
668 * @param object|null $connection_manager Connection manager object.
669 * @return void
670 */
671 public static function set_connection_manager( $connection_manager ) {
672 self::$connection_manager = $connection_manager;
673 }
674
675 /**
676 * Checks whether the current site is on a free Jetpack plan with no active paid license.
677 *
678 * @return bool True if the site has no paid plan.
679 */
680 private static function is_free_plan() {
681 // Check the active plan - use the is_free field or product_slug.
682 $plan = get_option( 'jetpack_active_plan', array() );
683
684 // Back-compat: older plan payloads use class to indicate paid plans.
685 if ( isset( $plan['class'] ) && 'free' !== $plan['class'] ) {
686 return false;
687 }
688
689 // If the plan explicitly says it's not free, trust that.
690 if ( isset( $plan['is_free'] ) && false === $plan['is_free'] ) {
691 return false;
692 }
693
694 // Check if the product slug indicates a paid plan.
695 if ( isset( $plan['product_slug'] ) && 'jetpack_free' !== $plan['product_slug'] ) {
696 return false;
697 }
698
699 // Also check for site products (licenses can add products without changing plan).
700 $products = get_option( 'jetpack_site_products', array() );
701 if ( ! empty( $products ) && is_array( $products ) ) {
702 return false;
703 }
704
705 return true;
706 }
707
708 /**
709 * Conditionally adds an "Upgrade Jetpack" submenu item for free-plan sites.
710 *
711 * Only shown to users with manage_options capability on self-hosted sites without a paid Jetpack plan or license.
712 *
713 * @return void
714 */
715 private static function maybe_add_upgrade_menu_item() {
716 if ( ! self::should_show_upgrade_menu() ) {
717 return;
718 }
719
720 $upgrade_url = class_exists( '\Automattic\Jetpack\Redirect' )
721 ? \Automattic\Jetpack\Redirect::get_url( self::UPGRADE_MENU_SLUG )
722 : self::UPGRADE_MENU_FALLBACK_URL;
723
724 $menu_title = esc_html__( 'Upgrade Jetpack', 'jetpack-admin-ui' );
725
726 add_submenu_page(
727 'jetpack',
728 $menu_title,
729 $menu_title,
730 'manage_options',
731 esc_url( $upgrade_url ),
732 null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
733 self::POSITION_UPGRADE
734 );
735
736 // Add a CSS class to the <li> element so styles can target it precisely.
737 global $submenu;
738 if ( ! empty( $submenu['jetpack'] ) ) {
739 foreach ( $submenu['jetpack'] as $index => $item ) {
740 if ( isset( $item[2] ) && false !== strpos( $item[2], self::UPGRADE_MENU_SLUG ) ) {
741 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
742 $submenu['jetpack'][ $index ][4] = ( ! empty( $item[4] ) ? $item[4] . ' ' : '' ) . self::UPGRADE_MENU_SLUG;
743 break;
744 }
745 }
746 }
747 }
748
749 /**
750 * Enqueues admin styles for the "Upgrade Jetpack" menu item.
751 *
752 * The sidebar menu is visible on every admin page, so styles load globally.
753 * Only enqueues for free-plan sites on self-hosted installs.
754 *
755 * @return void
756 */
757 public static function add_upgrade_menu_item_styles() {
758 if ( ! self::should_show_upgrade_menu() ) {
759 return;
760 }
761
762 $asset_file = dirname( __DIR__ ) . '/build/admin-ui-upgrade-menu.asset.php';
763 $asset = file_exists( $asset_file ) ? require $asset_file : array();
764
765 wp_enqueue_style(
766 'jetpack-admin-ui-upgrade-menu',
767 plugins_url( '../build/admin-ui-upgrade-menu.css', __FILE__ ),
768 $asset['dependencies'] ?? array(),
769 $asset['version'] ?? self::PACKAGE_VERSION
770 );
771
772 self::enqueue_upgrade_menu_tracks_script( $asset );
773 }
774
775 /**
776 * Enqueues the shared, token-only WPDS design-tokens stylesheet.
777 *
778 * Single entry point for any consumer that needs WPDS `var(--wpds-*)` values
779 * to resolve at runtime on a Jetpack admin page. Registers the handle on
780 * first use (idempotent) and enqueues it; the caller is responsible for
781 * scoping the call to the right page(s). Since admin-ui is a dependency of
782 * the Jetpack plugin and the modernized packages, both the plugin's
783 * legacy/wrap_ui gate and this package's own dashboards call through here,
784 * so the handle has a single owner and there is no duplicated enqueue logic.
785 *
786 * @return void
787 */
788 public static function enqueue_design_tokens() {
789 self::register_design_tokens_style();
790 wp_enqueue_style( self::DESIGN_TOKENS_HANDLE );
791 }
792
793 /**
794 * Registers the shared, token-only WPDS design-tokens stylesheet.
795 *
796 * The stylesheet only defines `:root{--wpds-*}` custom properties (no
797 * component or class styles), giving every Jetpack admin page a single
798 * runtime source for design tokens. It is safe to call repeatedly:
799 * wp_register_style() is a no-op once the handle is registered.
800 *
801 * @return void
802 */
803 private static function register_design_tokens_style() {
804 if ( wp_style_is( self::DESIGN_TOKENS_HANDLE, 'registered' ) ) {
805 return;
806 }
807
808 $asset_file = dirname( __DIR__ ) . '/build/design-tokens.asset.php';
809 $asset = file_exists( $asset_file ) ? require $asset_file : array();
810
811 wp_register_style(
812 self::DESIGN_TOKENS_HANDLE,
813 plugins_url( '../build/design-tokens.css', __FILE__ ),
814 $asset['dependencies'] ?? array(),
815 $asset['version'] ?? self::PACKAGE_VERSION
816 );
817 }
818
819 /**
820 * Enqueues the design tokens on the pages registered through this class.
821 *
822 * This is the admin_enqueue_scripts callback for the modernized Jetpack
823 * dashboards. Scoped to self::$page_hooks so the tokens load wherever a
824 * modernized dashboard renders, regardless of plan or connection state; the
825 * actual enqueue is delegated to the reusable enqueue_design_tokens() API.
826 *
827 * @param string $hook_suffix The current admin page's hook suffix.
828 * @return void
829 */
830 public static function maybe_enqueue_design_tokens( $hook_suffix ) {
831 if ( ! in_array( $hook_suffix, self::$page_hooks, true ) ) {
832 return;
833 }
834
835 self::enqueue_design_tokens();
836 }
837
838 /**
839 * Enqueues Tracks for the upgrade submenu item.
840 *
841 * @param array $asset Parsed contents of admin-ui-upgrade-menu.asset.php.
842 * @return void
843 */
844 private static function enqueue_upgrade_menu_tracks_script( $asset ) {
845 if ( ! class_exists( '\Automattic\Jetpack\Tracking' ) ) {
846 return;
847 }
848
849 Tracking::register_tracks_functions_scripts( true );
850
851 wp_enqueue_script(
852 'jetpack-admin-ui-upgrade-menu-tracking',
853 plugins_url( '../build/admin-ui-upgrade-menu-tracking.js', __FILE__ ),
854 $asset['dependencies'] ?? array(),
855 $asset['version'] ?? self::PACKAGE_VERSION,
856 true
857 );
858
859 $current_screen = get_current_screen();
860 $is_admin = current_user_can( 'jetpack_disconnect' );
861 $site_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null;
862 $tracks_user_data = class_exists( 'Jetpack_Tracks_Client' ) ? Jetpack_Tracks_Client::get_connected_user_tracks_identity() : null;
863
864 wp_localize_script(
865 'jetpack-admin-ui-upgrade-menu-tracking',
866 'jetpackAdminUiUpgradeMenu',
867 array(
868 'menuItemClass' => self::UPGRADE_MENU_SLUG,
869 'tracksUserData' => $tracks_user_data,
870 'tracksEventData' => array(
871 'is_admin' => $is_admin,
872 'current_screen' => $current_screen ? $current_screen->id : false,
873 'blog_id' => $site_id,
874 ),
875 )
876 );
877 }
878 }
879