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