label), for the settings checklist. */ public const SEEN_OPTION = 'featherdash_toolbar_items'; /** * Top-level items that belong to WordPress itself or to FeatherDash; never * treated as plugin items. */ private const OWN_NODES = [ 'menu-toggle', 'wp-logo', 'my-sites', 'site-name', 'site-editor', 'customize', 'updates', 'command-palette', 'comments', 'new-content', 'edit', 'view', 'preview', 'archive', 'get-shortlink', 'top-secondary', 'root-default', 'my-account', 'recovery-mode', 'search', 'fd-maintenance', 'featherdash-live', 'fd-notices' ]; /** * Top-level toolbar items of known plugins: id => [item label, plugin * name, how to tell the plugin is active (plugin file or constant)]. * * @var array */ private const KNOWN_NODES = [ 'kinsta-cache' => ['Clear Caches', 'Kinsta', 'const:KINSTAMU_VERSION'], 'gform-forms' => ['Forms', 'Gravity Forms', 'gravityforms/gravityforms.php'], 'monsterinsights_frontend_button' => ['Insights', 'MonsterInsights', 'google-analytics-for-wordpress/googleanalytics.php'], 'query-monitor' => ['Query Monitor stats', 'Query Monitor', 'query-monitor/query-monitor.php'], 'seopress' => ['SEO', 'SEOPress', 'wp-seopress/seopress.php'], 'tribe-events' => ['Events', 'The Events Calendar', 'the-events-calendar/the-events-calendar.php'], 'woocommerce-site-visibility-badge' => ['Store coming soon badge', 'WooCommerce', 'woocommerce/woocommerce.php'], 'wpseo-menu' => ['SEO', 'Yoast SEO', 'wordpress-seo/wp-seo.php'], ]; private Settings $settings; public function register( Settings $settings ) : void { $this->settings = $settings; add_action( 'admin_bar_menu', [$this, 'hideNodes'], 999 ); add_filter( 'show_admin_bar', [$this, 'maybeShow'] ); if ( $settings->get( 'admin_bar.replace_howdy', true ) ) { add_action( 'admin_bar_menu', [$this, 'replaceHowdy'], 9999 ); } // The account card is styled by the admin theme only. if ( is_admin() && $settings->get( 'admin.theme.enabled', true ) ) { add_action( 'admin_bar_menu', [$this, 'accountMenu'], 10000 ); } } /** * Rebuild the account dropdown as a card: the site (icon or initials, * name, domain), then the user (avatar, name, email), Edit profile and * Log out. Core's user-actions group is kept, so nodes other plugins add * to it stay in place. */ public function accountMenu( WP_Admin_Bar $bar ) : void { $user = wp_get_current_user(); // A hidden "My account" must stay hidden: add_node() would create it anew. if ( !$user->exists() || !is_object( $bar->get_node( 'my-account' ) ) || !is_object( $bar->get_node( 'user-actions' ) ) ) { return; } // The trigger only opens the card (no link to the profile). Without an // href core renders a div, so keep it in the tab order; core's // admin-bar.js opens it with Enter. $bar->add_node( [ 'id' => 'my-account', 'href' => false, 'meta' => [ 'tabindex' => 0, ], ] ); // Re-add core's group after ours so the site section comes first. $group = $bar->get_node( 'user-actions' ); $bar->remove_node( 'user-actions' ); $siteName = (string) get_bloginfo( 'name' ); $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $icon = get_site_icon_url( 64 ); $badge = ( $icon !== '' ? '' : '' ); $bar->add_group( [ 'parent' => 'my-account', 'id' => 'fd-account-site', ] ); $bar->add_node( [ 'parent' => 'fd-account-site', 'id' => 'fd-account-site-link', 'title' => $badge . '' . '', 'href' => home_url( '/' ), ] ); $bar->add_group( [ 'parent' => 'my-account', 'id' => 'user-actions', 'meta' => (array) ($group->meta ?? []), ] ); $logout = $bar->get_node( 'logout' ); $bar->remove_node( 'logout' ); $bar->add_node( [ 'parent' => 'user-actions', 'id' => 'user-info', 'title' => get_avatar( $user->ID, 64, '', '', [ 'class' => 'fd-account-avatar', ] ) . '' . '', 'href' => get_edit_profile_url( $user->ID ), 'meta' => [ 'tabindex' => -1, ], ] ); $bar->add_node( [ 'parent' => 'user-actions', 'id' => 'edit-profile', 'title' => esc_html__( 'Edit profile', 'featherdash' ), 'href' => get_edit_profile_url( $user->ID ), 'meta' => [ 'class' => 'fd-account-link', ], ] ); if ( is_object( $logout ) ) { $bar->add_node( [ 'parent' => 'user-actions', 'id' => 'logout', 'title' => esc_html__( 'Log out', 'featherdash' ), 'href' => (string) $logout->href, 'meta' => [ 'class' => 'fd-account-link', ], ] ); } } /** * Up to three initials from a name, e.g. "FeatherDash Dev" → "XD". */ private static function initials( string $name ) : string { $words = ( preg_split( '/[\\s._-]+/u', trim( $name ), -1, PREG_SPLIT_NO_EMPTY ) ?: [] ); $letters = ''; // mbstring is optional in PHP: fall back to the first byte and strtoupper(). $multibyte = function_exists( 'mb_substr' ) && function_exists( 'mb_strtoupper' ); foreach ( array_slice( $words, 0, 3 ) as $word ) { $letters .= ( $multibyte ? mb_substr( $word, 0, 1 ) : substr( $word, 0, 1 ) ); } $letters = ( $letters !== '' ? $letters : '?' ); return ( $multibyte ? mb_strtoupper( $letters ) : strtoupper( $letters ) ); } public function hideNodes( WP_Admin_Bar $bar ) : void { $show = array_map( 'strval', (array) $this->settings->get( 'admin_bar.show', [] ) ); $options = Schema::fieldTypes()['admin_bar.show']['options'] ?? []; // The mobile menu toggle is never in the list, so it always stays. foreach ( array_diff( $options, $show ) as $node ) { $bar->remove_node( $node ); } } /** * Options for the "Visible toolbar items added by plugins" checklist: * known plugin items (greyed out when the plugin is inactive) plus any * other plugin items recorded at runtime. * * @return array */ public static function pluginNodeOptions() : array { if ( !function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $options = []; /** * Filters the plugin toolbar items listed on the Toolbar tab even before * they're seen: `id => [label, plugin name, plugin file or 'const:NAME']`. * * @param array $known */ $known = (array) apply_filters( 'featherdash/toolbar/known_items', self::KNOWN_NODES ); foreach ( $known as $id => [$label, $plugin, $check] ) { $active = ( str_starts_with( $check, 'const:' ) ? defined( substr( $check, 6 ) ) : is_plugin_active( $check ) ); $options[$id] = [ 'label' => $label, 'plugin' => $plugin, 'active' => $active, ]; } $seen = get_option( self::SEEN_OPTION, [] ); foreach ( ( is_array( $seen ) ? $seen : [] ) as $id => $label ) { $options[(string) $id] ??= [ 'label' => ( trim( preg_replace( '/\\s*\\d+.*$/', '', (string) $label ) ?? '' ) ?: (string) $id ), 'plugin' => '', 'active' => true, ]; } // Active plugins first, then by plugin and item name. uasort( $options, static fn( array $a, array $b ): int => [!$a['active'], ( $a['plugin'] ?: $a['label'] ), $a['label']] <=> [!$b['active'], ( $b['plugin'] ?: $b['label'] ), $b['label']] ); return $options; } /** * @param array $found */ private function rememberNodes( array $found ) : void { if ( $found === [] || !current_user_can( 'manage_options' ) ) { return; } $seen = get_option( self::SEEN_OPTION, [] ); $seen = ( is_array( $seen ) ? $seen : [] ); // The first-seen label wins: live titles often carry counters. $merged = $seen + $found; if ( $merged != $seen ) { ksort( $merged ); update_option( self::SEEN_OPTION, $merged, false ); } } public function maybeShow( bool $show ) : bool { if ( !$show || is_admin() ) { return $show; } if ( current_user_can( 'manage_options' ) ) { return (bool) $this->settings->get( 'admin_bar.frontend.administrator', true ); } return (bool) $this->settings->get( 'admin_bar.frontend.others', false ); } public function replaceHowdy( WP_Admin_Bar $bar ) : void { $node = $bar->get_node( 'my-account' ); if ( !is_object( $node ) || !isset( $node->title ) ) { return; } // Core's title is "Howdy, …" plus the avatar, in any // language: keep the name and the avatar, drop the greeting around them. if ( preg_match( '#.*?#s', (string) $node->title, $name ) !== 1 ) { return; } preg_match( '#]*>#', (string) $node->title, $avatar ); $title = $name[0] . ($avatar[0] ?? ''); // add_node() merges into the existing node, updating the title in place. $bar->add_node( [ 'id' => 'my-account', 'title' => $title, ] ); } }