diff --git a/src/js/_enqueues/lib/admin-bar.js b/src/js/_enqueues/lib/admin-bar.js index eb18c61f8b5b8..ea3c995c85360 100644 --- a/src/js/_enqueues/lib/admin-bar.js +++ b/src/js/_enqueues/lib/admin-bar.js @@ -108,6 +108,61 @@ if ( adminBarLogout ) { adminBarLogout.addEventListener( 'click', emptySessionStorage ); } + + /* + * My Sites fly-out positioning. + * + * When the My Sites dropdown is scrollable (overflow-y: auto), the nested + * fly-out submenus (Dashboard, New Post, etc.) are clipped by the scroll + * container. The CSS switches them to position: fixed, and this JS computes + * their coordinates from getBoundingClientRect() so they appear next to + * the hovered site. The fly-out is clamped so it never extends below the + * viewport. + * + * @since 7.0.0 + * @see https://core.trac.wordpress.org/ticket/15317 + */ + var mySitesWrapper = document.querySelector( '#wp-admin-bar-my-sites > .ab-sub-wrapper' ); + + if ( mySitesWrapper ) { + mySitesWrapper.addEventListener( 'mouseover', function( e ) { + var li = getClosest( e.target, '.menupop' ); + + if ( ! li || li.id === 'wp-admin-bar-my-sites' ) { + return; + } + + var sub = li.querySelector( ':scope > .ab-sub-wrapper' ); + + if ( ! sub ) { + return; + } + + var rect = li.getBoundingClientRect(); + var top = rect.top; + var isRTL = ( document.documentElement.dir === 'rtl' ); + + // Measure the fly-out to keep it inside the viewport. + sub.style.visibility = 'hidden'; + sub.style.display = 'block'; + var subHeight = sub.offsetHeight; + var subWidth = sub.offsetWidth; + sub.style.removeProperty( 'visibility' ); + sub.style.removeProperty( 'display' ); + + if ( top + subHeight > window.innerHeight ) { + top = Math.max( 0, window.innerHeight - subHeight ); + } + + li.style.setProperty( '--msf-top', top + 'px' ); + + if ( isRTL ) { + li.style.setProperty( '--msf-left', Math.max( 0, rect.left - subWidth ) + 'px' ); + } else { + li.style.setProperty( '--msf-left', rect.right + 'px' ); + } + } ); + } } ); /** diff --git a/src/wp-includes/admin-bar.php b/src/wp-includes/admin-bar.php index b9c7872d0cc07..5ba005c0f121f 100644 --- a/src/wp-includes/admin-bar.php +++ b/src/wp-includes/admin-bar.php @@ -675,18 +675,30 @@ function wp_admin_bar_my_sites_menu( $wp_admin_bar ) { * * @param bool $show_site_icons Whether site icons should be shown in the toolbar. Default true. */ - $show_site_icons = apply_filters( 'wp_admin_bar_show_site_icons', true ); - - foreach ( (array) $wp_admin_bar->user->blogs as $blog ) { - switch_to_blog( $blog->userblog_id ); + $blogs = (array) $wp_admin_bar->user->blogs; + $show_site_icons = apply_filters( 'wp_admin_bar_show_site_icons', count( $blogs ) <= 20 ); + + foreach ( $blogs as $blog ) { + $blog_id = (int) $blog->userblog_id; + $siteurl = untrailingslashit( $blog->siteurl ); + $adminurl = $siteurl . '/wp-admin'; + $homeurl = esc_url( set_url_scheme( 'http://' . $blog->domain . $blog->path ) ); + + if ( $show_site_icons ) { + switch_to_blog( $blog_id ); + + if ( has_site_icon() ) { + $blavatar = sprintf( + '', + esc_url( get_site_icon_url( 16 ) ), + esc_url( get_site_icon_url( 32 ) ), + ( wp_lazy_loading_enabled( 'img', 'site_icon_in_toolbar' ) ? ' loading="lazy"' : '' ) + ); + } else { + $blavatar = '
'; + } - if ( true === $show_site_icons && has_site_icon() ) { - $blavatar = sprintf( - '', - esc_url( get_site_icon_url( 16 ) ), - esc_url( get_site_icon_url( 32 ) ), - ( wp_lazy_loading_enabled( 'img', 'site_icon_in_toolbar' ) ? ' loading="lazy"' : '' ) - ); + restore_current_blog(); } else { $blavatar = '
'; } @@ -694,72 +706,55 @@ function wp_admin_bar_my_sites_menu( $wp_admin_bar ) { $blogname = $blog->blogname; if ( ! $blogname ) { - $blogname = preg_replace( '#^(https?://)?(www\.)?#', '', get_home_url() ); + $blogname = preg_replace( '#^(https?://)?(www\.)?#', '', $siteurl ); } - $menu_id = 'blog-' . $blog->userblog_id; + $menu_id = 'blog-' . $blog_id; - if ( current_user_can( 'read' ) ) { - $wp_admin_bar->add_node( - array( - 'parent' => 'my-sites-list', - 'id' => $menu_id, - 'title' => $blavatar . $blogname, - 'href' => admin_url(), - ) - ); + $wp_admin_bar->add_node( + array( + 'parent' => 'my-sites-list', + 'id' => $menu_id, + 'title' => $blavatar . esc_html( $blogname ), + 'href' => $adminurl, + ) + ); - $wp_admin_bar->add_node( - array( - 'parent' => $menu_id, - 'id' => $menu_id . '-d', - 'title' => __( 'Dashboard' ), - 'href' => admin_url(), - ) - ); - } else { - $wp_admin_bar->add_node( - array( - 'parent' => 'my-sites-list', - 'id' => $menu_id, - 'title' => $blavatar . $blogname, - 'href' => home_url(), - ) - ); - } + $wp_admin_bar->add_node( + array( + 'parent' => $menu_id, + 'id' => $menu_id . '-d', + 'title' => __( 'Dashboard' ), + 'href' => $adminurl, + ) + ); - if ( current_user_can( get_post_type_object( 'post' )->cap->create_posts ) ) { - $wp_admin_bar->add_node( - array( - 'parent' => $menu_id, - 'id' => $menu_id . '-n', - 'title' => get_post_type_object( 'post' )->labels->new_item, - 'href' => admin_url( 'post-new.php' ), - ) - ); - } + $wp_admin_bar->add_node( + array( + 'parent' => $menu_id, + 'id' => $menu_id . '-n', + 'title' => __( 'New Post' ), + 'href' => $adminurl . '/post-new.php', + ) + ); - if ( current_user_can( 'edit_posts' ) ) { - $wp_admin_bar->add_node( - array( - 'parent' => $menu_id, - 'id' => $menu_id . '-c', - 'title' => __( 'Manage Comments' ), - 'href' => admin_url( 'edit-comments.php' ), - ) - ); - } + $wp_admin_bar->add_node( + array( + 'parent' => $menu_id, + 'id' => $menu_id . '-c', + 'title' => __( 'Manage Comments' ), + 'href' => $adminurl . '/edit-comments.php', + ) + ); $wp_admin_bar->add_node( array( 'parent' => $menu_id, 'id' => $menu_id . '-v', 'title' => __( 'Visit Site' ), - 'href' => home_url( '/' ), + 'href' => $homeurl, ) ); - - restore_current_blog(); } } diff --git a/src/wp-includes/css/admin-bar.css b/src/wp-includes/css/admin-bar.css index 0b8d4ab41a809..d4a20dd38fc9e 100644 --- a/src/wp-includes/css/admin-bar.css +++ b/src/wp-includes/css/admin-bar.css @@ -763,6 +763,32 @@ html:lang(he-il) .rtl #wpadminbar * { box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.6); } +/* + * My Sites scrollable dropdown. + * + * When the site list exceeds the viewport height, allow the menu to scroll. + * Fly-out submenus use position: fixed to escape the scroll container's + * overflow clipping. Coordinates are set via CSS custom properties from JS. + * + * @since 7.0.0 + * @see https://core.trac.wordpress.org/ticket/15317 + */ +@media screen and (min-width: 783px) { + #wpadminbar .ab-top-menu > li#wp-admin-bar-my-sites > .ab-sub-wrapper { + max-height: calc(100vh - var(--wp-admin--admin-bar--height, 32px)); + overflow-y: auto; + } + + /* Fly-out submenus: fixed position to escape the scroll container. */ + #wpadminbar #wp-admin-bar-my-sites .ab-sub-wrapper .menupop > .ab-sub-wrapper { + position: fixed !important; + margin-left: 0 !important; + margin-top: 0 !important; + top: var(--msf-top, 0); + left: var(--msf-left, 0); + } +} + @media screen and (max-width: 782px) { html { --wp-admin--admin-bar--height: 46px; diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 9c635f63d288a..031cb7340d0a3 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -1080,6 +1080,57 @@ function get_blogs_of_user( $user_id, $all = false ) { return $sites; } + /* + * Super admins have implicit access to all sites on the network, + * but only have explicit `_capabilities` meta rows for sites they + * were individually added to. Use get_sites() so they see every site. + * + * @since 7.0.0 + */ + if ( is_super_admin( $user_id ) ) { + $args = array( + 'orderby' => 'path', + 'number' => 0, // All sites. + ); + if ( ! $all ) { + $args['archived'] = 0; + $args['spam'] = 0; + $args['deleted'] = 0; + $args['mature'] = 0; + } + + $_sites = get_sites( $args ); + + $_site_ids = array_map( + function ( $s ) { + return (int) $s->id; + }, + $_sites + ); + + $_site_options = _batch_get_site_options( $_site_ids, array( 'blogname', 'siteurl' ) ); + + $sites = array(); + foreach ( $_sites as $site ) { + $id = (int) $site->id; + $sites[ $id ] = (object) array( + 'userblog_id' => $id, + 'blogname' => isset( $_site_options[ $id ]['blogname'] ) ? $_site_options[ $id ]['blogname'] : '', + 'domain' => $site->domain, + 'path' => $site->path, + 'site_id' => (int) $site->network_id, + 'siteurl' => isset( $_site_options[ $id ]['siteurl'] ) ? $_site_options[ $id ]['siteurl'] : '', + 'archived' => $site->archived, + 'mature' => $site->mature, + 'spam' => $site->spam, + 'deleted' => $site->deleted, + ); + } + + /** This filter is documented in wp-includes/user.php */ + return apply_filters( 'get_blogs_of_user', $sites, $user_id, $all ); + } + $site_ids = array(); if ( isset( $keys[ $wpdb->base_prefix . 'capabilities' ] ) && defined( 'MULTISITE' ) ) { @@ -1119,14 +1170,36 @@ function get_blogs_of_user( $user_id, $all = false ) { $_sites = get_sites( $args ); + /* + * Batch-fetch blogname and siteurl for all sites in a single query. + * + * Accessing $site->blogname or $site->siteurl on a WP_Site object + * triggers WP_Site::get_details(), which calls switch_to_blog() + * internally for each site. On large networks this is expensive. + * + * Instead, build a UNION ALL query across per-site options tables + * to fetch both values for all sites at once. + * + * @since 7.0.0 + */ + $_site_ids = array_map( + function ( $s ) { + return (int) $s->id; + }, + $_sites + ); + + $_site_options = _batch_get_site_options( $_site_ids, array( 'blogname', 'siteurl' ) ); + foreach ( $_sites as $site ) { - $sites[ $site->id ] = (object) array( - 'userblog_id' => $site->id, - 'blogname' => $site->blogname, + $id = (int) $site->id; + $sites[ $id ] = (object) array( + 'userblog_id' => $id, + 'blogname' => isset( $_site_options[ $id ]['blogname'] ) ? $_site_options[ $id ]['blogname'] : '', 'domain' => $site->domain, 'path' => $site->path, 'site_id' => $site->network_id, - 'siteurl' => $site->siteurl, + 'siteurl' => isset( $_site_options[ $id ]['siteurl'] ) ? $_site_options[ $id ]['siteurl'] : '', 'archived' => $site->archived, 'mature' => $site->mature, 'spam' => $site->spam, @@ -1148,6 +1221,57 @@ function get_blogs_of_user( $user_id, $all = false ) { return apply_filters( 'get_blogs_of_user', $sites, $user_id, $all ); } +/** + * Batch-fetch option values for multiple sites in a single SQL query. + * + * Uses $wpdb->get_blog_prefix() (a pure function with no side effects) to + * build a UNION ALL query across per-site options tables. This avoids the + * hidden switch_to_blog() calls triggered by WP_Site::get_details() when + * accessing magic properties like blogname or siteurl. + * + * @since 7.0.0 + * @access private + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param int[] $site_ids Array of site IDs. + * @param string[] $option_names Array of option names to fetch. + * @return array> Keyed by site ID, then option name. + */ +function _batch_get_site_options( array $site_ids, array $option_names ) { + if ( empty( $site_ids ) || empty( $option_names ) ) { + return array(); + } + + global $wpdb; + + $name_placeholders = implode( ',', array_fill( 0, count( $option_names ), '%s' ) ); + + $union_parts = array(); + foreach ( $site_ids as $site_id ) { + $site_id = (int) $site_id; + $prefix = $wpdb->get_blog_prefix( $site_id ); + $table = $prefix . 'options'; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->get_blog_prefix(). + $union_parts[] = $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table and $name_placeholders are safe. + "SELECT %d AS blog_id, option_name, option_value FROM {$table} WHERE option_name IN ({$name_placeholders})", + array_merge( array( $site_id ), $option_names ) + ); + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- each part is prepared above. + $rows = $wpdb->get_results( implode( ' UNION ALL ', $union_parts ) ); + + $options = array(); + foreach ( $rows as $row ) { + $options[ (int) $row->blog_id ][ $row->option_name ] = $row->option_value; + } + + return $options; +} + /** * Finds out whether a user is a member of a given blog. *