PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / _inc / site-switcher.jsx
jetpack / _inc Last commit date
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