PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
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 13.8.3 All 506 releases
jetpack / _inc / site-switcher.jsx

site-switcher.jsx in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at _inc/site-switcher.jsx

286 lines 7.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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