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