blocks
2 days ago
build
2 days ago
content-guidelines-ai
2 days ago
fonts
1 year ago
genericons
4 months ago
lib
2 days ago
accessible-focus.js
5 years ago
blogging-prompts.php
1 week ago
class.jetpack-provision.php
7 months ago
content-guidelines-ai.js
1 week ago
content-guidelines-ai.php
1 week ago
crowdsignal-shortcode.js
1 year ago
crowdsignal-survey.js
5 years ago
deprecate.js
7 months ago
facebook-embed.js
4 years ago
gallery-settings.js
5 years ago
genericons.php
1 year ago
jetpack-admin.js
3 years ago
jetpack-deactivate-dialog.js
1 year ago
jetpack-modules.js
1 year ago
jetpack-modules.models.js
7 months ago
jetpack-modules.views.js
7 months ago
polldaddy-shortcode.js
3 months ago
site-switcher-endpoint.php
5 months ago
site-switcher.js
5 months ago
site-switcher.php
5 months ago
social-logos.php
3 months ago
twitter-timeline.js
1 month ago
site-switcher.js
289 lines
| 1 | /** |
| 2 | * Site Switcher for Command Palette |
| 3 | * Adds a dynamic "Switch to Site" command that searches across all user's WordPress.com sites |
| 4 | * |
| 5 | * Requires WordPress 6.9+ for admin-wide command palette support |
| 6 | * |
| 7 | * @package |
| 8 | */ |
| 9 | |
| 10 | import apiFetch from '@wordpress/api-fetch'; |
| 11 | import { useCommandLoader } from '@wordpress/commands'; |
| 12 | import { useMemo, useState, useEffect } from '@wordpress/element'; |
| 13 | import { sprintf, __ } from '@wordpress/i18n'; |
| 14 | import { siteLogo } from '@wordpress/icons'; |
| 15 | |
| 16 | const userId = window.jetpackSiteSwitcherConfig?.userId || 0; |
| 17 | const CACHE_KEY = `jetpack_site_switcher_sites_${ userId }`; |
| 18 | const CACHE_DURATION = 3600000; // 1 hour in milliseconds |
| 19 | |
| 20 | /** |
| 21 | * Get cached sites from localStorage |
| 22 | */ |
| 23 | function getCachedSites() { |
| 24 | try { |
| 25 | const cached = localStorage.getItem( CACHE_KEY ); |
| 26 | if ( ! cached ) { |
| 27 | return null; |
| 28 | } |
| 29 | |
| 30 | const { sites, timestamp } = JSON.parse( cached ); |
| 31 | |
| 32 | // Check if cache is still valid |
| 33 | if ( Date.now() - timestamp < CACHE_DURATION ) { |
| 34 | return sites; |
| 35 | } |
| 36 | |
| 37 | // Cache expired, remove it |
| 38 | localStorage.removeItem( CACHE_KEY ); |
| 39 | return null; |
| 40 | } catch { |
| 41 | // If localStorage is not available or JSON parsing fails, return null |
| 42 | return null; |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * Save sites to localStorage cache |
| 48 | */ |
| 49 | function setCachedSites( sites ) { |
| 50 | try { |
| 51 | localStorage.setItem( |
| 52 | CACHE_KEY, |
| 53 | JSON.stringify( { |
| 54 | sites, |
| 55 | timestamp: Date.now(), |
| 56 | } ) |
| 57 | ); |
| 58 | } catch { |
| 59 | // Silently fail if localStorage is not available (e.g., private browsing) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Fetch compact sites list from WordPress.com API |
| 65 | */ |
| 66 | async function fetchSitesFromWordPressCom() { |
| 67 | // Check localStorage cache first |
| 68 | const cachedSites = getCachedSites(); |
| 69 | if ( cachedSites ) { |
| 70 | return cachedSites; |
| 71 | } |
| 72 | |
| 73 | const apiPath = window.jetpackSiteSwitcherConfig?.apiPath; |
| 74 | |
| 75 | try { |
| 76 | const data = await apiFetch( { |
| 77 | path: apiPath, |
| 78 | method: 'GET', |
| 79 | global: true, |
| 80 | } ); |
| 81 | |
| 82 | const sites = data.sites || []; |
| 83 | |
| 84 | setCachedSites( sites ); |
| 85 | |
| 86 | return sites; |
| 87 | } catch { |
| 88 | return []; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Safely extract hostname from a URL string |
| 94 | * |
| 95 | * @param {string} urlString - The URL to parse |
| 96 | * @return {string} The hostname, or empty string if invalid |
| 97 | */ |
| 98 | function getHostnameFromURL( urlString ) { |
| 99 | if ( ! urlString ) { |
| 100 | return ''; |
| 101 | } |
| 102 | try { |
| 103 | return new URL( urlString ).hostname; |
| 104 | } catch { |
| 105 | return ''; |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Remove trailing slash from a URL string |
| 111 | * |
| 112 | * @param {string} url - The URL to process |
| 113 | * @return {string} URL without trailing slash |
| 114 | */ |
| 115 | function untrailingslashit( url ) { |
| 116 | return url ? url.replace( /\/+$/, '' ) : url; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Escape special regex characters in a string |
| 121 | * |
| 122 | * @param {string} str - String to escape |
| 123 | * @return {string} Escaped string safe for use in RegExp |
| 124 | */ |
| 125 | function escapeRegex( str ) { |
| 126 | return str.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Custom hook to load site-switching commands based on search term |
| 131 | * |
| 132 | * @param {Object} props - Hook properties |
| 133 | * @param {string} props.search - Search term to filter sites |
| 134 | * @return {Object} Object containing commands array and loading state |
| 135 | */ |
| 136 | function useSiteSwitcherCommandLoader( { search } ) { |
| 137 | const [ sites, setSites ] = useState( [] ); |
| 138 | const [ isLoading, setIsLoading ] = useState( true ); |
| 139 | |
| 140 | // Fetch sites on mount |
| 141 | useEffect( () => { |
| 142 | fetchSitesFromWordPressCom() |
| 143 | .then( fetchedSites => { |
| 144 | setSites( fetchedSites ); |
| 145 | setIsLoading( false ); |
| 146 | } ) |
| 147 | .catch( () => { |
| 148 | setIsLoading( false ); |
| 149 | } ); |
| 150 | }, [] ); |
| 151 | |
| 152 | // Generate and filter commands based on search term |
| 153 | const commands = useMemo( () => { |
| 154 | if ( ! sites || sites.length === 0 ) { |
| 155 | return []; |
| 156 | } |
| 157 | |
| 158 | const searchLower = search ? search.toLowerCase() : ''; |
| 159 | |
| 160 | // Strip generic keywords from search to allow queries like "site dean" to find sites with "dean" |
| 161 | const genericKeywords = [ |
| 162 | __( 'site', 'jetpack' ).toLowerCase(), |
| 163 | __( 'switch', 'jetpack' ).toLowerCase(), |
| 164 | __( 'switch site', 'jetpack' ).toLowerCase(), |
| 165 | ]; |
| 166 | |
| 167 | let cleanedSearch = searchLower; |
| 168 | genericKeywords.forEach( keyword => { |
| 169 | cleanedSearch = cleanedSearch.replace( |
| 170 | new RegExp( `\\b${ escapeRegex( keyword ) }\\b`, 'g' ), |
| 171 | ' ' |
| 172 | ); |
| 173 | } ); |
| 174 | cleanedSearch = cleanedSearch.trim().replace( /\s+/g, ' ' ); |
| 175 | |
| 176 | // Check if the search is a prefix of any generic keyword (e.g., "swit" matches "switch") |
| 177 | // If so, treat it as a generic search and show all sites |
| 178 | const isGenericKeywordPrefix = |
| 179 | cleanedSearch && genericKeywords.some( keyword => keyword.startsWith( cleanedSearch ) ); |
| 180 | |
| 181 | // If search is empty after stripping generic keywords, or is a prefix of a generic keyword, show all sites |
| 182 | const filteredSites = |
| 183 | ! cleanedSearch || isGenericKeywordPrefix |
| 184 | ? sites |
| 185 | : sites.filter( site => { |
| 186 | const domain = getHostnameFromURL( site.URL ); |
| 187 | return ( |
| 188 | ( site.name && site.name.toLowerCase().includes( cleanedSearch ) ) || |
| 189 | domain.toLowerCase().includes( cleanedSearch ) |
| 190 | ); |
| 191 | } ); |
| 192 | |
| 193 | // Filter out sites with invalid URLs (can't navigate to them anyway) |
| 194 | const validSites = filteredSites.filter( site => { |
| 195 | return site.URL && getHostnameFromURL( site.URL ) !== ''; |
| 196 | } ); |
| 197 | |
| 198 | // Exclude the current site from the list |
| 199 | const currentURL = untrailingslashit( window.location.href.toLowerCase() ); |
| 200 | const otherSites = validSites.filter( site => { |
| 201 | // Normalize site URL for comparison |
| 202 | const siteURL = untrailingslashit( site.URL.toLowerCase() ); |
| 203 | // Check if current URL starts with site URL (handles multisite subdirectory installs) |
| 204 | // e.g., current: example.com/site1/wp-admin matches site: example.com/site1 |
| 205 | return ! currentURL.startsWith( siteURL ); |
| 206 | } ); |
| 207 | |
| 208 | return otherSites.map( site => { |
| 209 | // Extract domain from URL for display - don't want to display the protocol. |
| 210 | const domain = getHostnameFromURL( site.URL ); |
| 211 | |
| 212 | const iconElement = site.icon?.img ? <img src={ site.icon.img } alt="" /> : siteLogo; |
| 213 | |
| 214 | // Use site name if available, otherwise just show domain |
| 215 | const label = site.name |
| 216 | ? sprintf( |
| 217 | /* translators: %1$s: site name, %2$s: site domain */ |
| 218 | __( 'Switch to %1$s (%2$s)', 'jetpack' ), |
| 219 | site.name, |
| 220 | domain |
| 221 | ) |
| 222 | : sprintf( |
| 223 | /* translators: %s: site domain */ |
| 224 | __( 'Switch to %s', 'jetpack' ), |
| 225 | domain |
| 226 | ); |
| 227 | |
| 228 | return { |
| 229 | name: `jetpack/switch-to-site-${ domain }`, |
| 230 | label, |
| 231 | icon: iconElement, |
| 232 | callback: ( { close } ) => { |
| 233 | try { |
| 234 | window.location.href = new URL( '/wp-admin', site.URL ).href; |
| 235 | } catch { |
| 236 | // If URL is malformed, don't navigate |
| 237 | } |
| 238 | close(); |
| 239 | }, |
| 240 | keywords: [ |
| 241 | site.name, |
| 242 | domain, |
| 243 | __( 'site', 'jetpack' ), |
| 244 | __( 'switch site', 'jetpack' ), |
| 245 | ].filter( Boolean ), |
| 246 | }; |
| 247 | } ); |
| 248 | }, [ sites, search ] ); |
| 249 | |
| 250 | return { |
| 251 | commands, |
| 252 | isLoading, |
| 253 | }; |
| 254 | } |
| 255 | |
| 256 | /** |
| 257 | * Component that registers the site switcher command loader |
| 258 | */ |
| 259 | function JetpackSiteSwitcher() { |
| 260 | useCommandLoader( { |
| 261 | name: 'jetpack/site-switcher', |
| 262 | hook: useSiteSwitcherCommandLoader, |
| 263 | } ); |
| 264 | |
| 265 | return null; |
| 266 | } |
| 267 | |
| 268 | // Render the site switcher into wp-admin |
| 269 | // This works with WordPress 6.9+ admin-wide command palette |
| 270 | if ( typeof window !== 'undefined' && window.wp && window.wp.element && window.wp.commands ) { |
| 271 | const { createRoot, createElement } = window.wp.element; |
| 272 | |
| 273 | // Create a container for our site switcher |
| 274 | const container = document.createElement( 'div' ); |
| 275 | container.id = 'jetpack-site-switcher'; |
| 276 | container.style.display = 'none'; // Hidden, as we only need the hooks to run |
| 277 | |
| 278 | // Wait for DOM to be ready |
| 279 | if ( document.readyState === 'loading' ) { |
| 280 | document.addEventListener( 'DOMContentLoaded', () => { |
| 281 | document.body.appendChild( container ); |
| 282 | createRoot( container ).render( createElement( JetpackSiteSwitcher ) ); |
| 283 | } ); |
| 284 | } else { |
| 285 | document.body.appendChild( container ); |
| 286 | createRoot( container ).render( createElement( JetpackSiteSwitcher ) ); |
| 287 | } |
| 288 | } |
| 289 |