Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions projects/plugins/jetpack/_inc/site-switcher-endpoint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
/**
* Site Switcher REST API Endpoint
* Jetpack-only endpoint for fetching compact sites list
*
* @package automattic/jetpack
*/

use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;

if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}

/**
* Register REST API endpoint to fetch compact sites list from WordPress.com
*/
function jetpack_site_switcher_register_rest_routes() {
register_rest_route(
'jetpack/v4',
'/sites/compact',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'jetpack_site_switcher_get_sites',
'permission_callback' => 'jetpack_site_switcher_permission_check',
)
);
}

/**
* Check if the current user is connected to WordPress.com
*
* @return bool True if user is connected, false otherwise
*/
function jetpack_site_switcher_permission_check() {
if ( ! is_user_logged_in() ) {
return false;
}

$connection_manager = new Connection_Manager();
return $connection_manager->is_user_connected();
}
add_action( 'rest_api_init', 'jetpack_site_switcher_register_rest_routes' );

/**
* Fetch compact sites list from WordPress.com API
*
* @return WP_REST_Response|WP_Error
*/
function jetpack_site_switcher_get_sites() {
$response = Client::wpcom_json_api_request_as_user(
'/me/sites/compact',
'v1.1',
array( 'method' => 'GET' ),
null,
'rest'
);

if ( is_wp_error( $response ) ) {
return new WP_Error(
'jetpack_site_switcher_request_failed',
sprintf(
/* translators: %s: Error message from the API request */
__( 'Failed to connect to WordPress.com: %s', 'jetpack' ),
$response->get_error_message()
),
array( 'status' => 500 )
);
}

$response_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $response_code ) {
return new WP_Error(
'jetpack_site_switcher_api_error',
sprintf(
/* translators: %d: HTTP status code */
__( 'WordPress.com API returned error (HTTP %d)', 'jetpack' ),
$response_code
),
array( 'status' => $response_code )
);
}

$body = json_decode( wp_remote_retrieve_body( $response ), true );

if ( ! isset( $body['sites'] ) ) {
return new WP_Error(
'jetpack_site_switcher_invalid_response',
__( 'WordPress.com API response missing sites data', 'jetpack' ),
array( 'status' => 500 )
);
}

return rest_ensure_response( $body );
}
259 changes: 259 additions & 0 deletions projects/plugins/jetpack/_inc/site-switcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
/**
* Site Switcher for Command Palette
* Adds a dynamic "Switch to Site" command that searches across all user's WordPress.com sites
*
* Requires WordPress 6.9+ for admin-wide command palette support
*
* @package
*/

import apiFetch from '@wordpress/api-fetch';
import { useCommandLoader } from '@wordpress/commands';
import { useMemo, useState, useEffect } from '@wordpress/element';
import { sprintf, __ } from '@wordpress/i18n';
import { siteLogo } from '@wordpress/icons';

const userId = window.jetpackSiteSwitcherConfig?.userId || 0;
const CACHE_KEY = `jetpack_site_switcher_sites_${ userId }`;
const CACHE_DURATION = 3600000; // 1 hour in milliseconds

/**
* Get cached sites from localStorage
*/
function getCachedSites() {
try {
const cached = localStorage.getItem( CACHE_KEY );
if ( ! cached ) {
return null;
}

const { sites, timestamp } = JSON.parse( cached );

// Check if cache is still valid
if ( Date.now() - timestamp < CACHE_DURATION ) {
return sites;
}

// Cache expired, remove it
localStorage.removeItem( CACHE_KEY );
return null;
} catch {
// If localStorage is not available or JSON parsing fails, return null
return null;
}
}

/**
* Save sites to localStorage cache
*/
function setCachedSites( sites ) {
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify( {
sites,
timestamp: Date.now(),
} )
);
} catch {
// Silently fail if localStorage is not available (e.g., private browsing)
}
}

/**
* Fetch compact sites list from WordPress.com API
*/
async function fetchSitesFromWordPressCom() {
// Check localStorage cache first
const cachedSites = getCachedSites();
if ( cachedSites ) {
return cachedSites;
}

const apiPath = window.jetpackSiteSwitcherConfig?.apiPath;

try {
const data = await apiFetch( {
path: apiPath,
method: 'GET',
global: true,
} );

const sites = data.sites || [];

setCachedSites( sites );

return sites;
} catch {
return [];
}
}

/**
* Safely extract hostname from a URL string
*
* @param {string} urlString - The URL to parse
* @return {string} The hostname, or empty string if invalid
*/
function getHostnameFromURL( urlString ) {
if ( ! urlString ) {
return '';
}
try {
return new URL( urlString ).hostname;
} catch {
return '';
}
}

/**
* Remove trailing slash from a URL string
*
* @param {string} url - The URL to process
* @return {string} URL without trailing slash
*/
function untrailingslashit( url ) {
return url ? url.replace( /\/+$/, '' ) : url;
}

/**
* Custom hook to load site-switching commands based on search term
*
* @param {Object} props - Hook properties
* @param {string} props.search - Search term to filter sites
* @return {Object} Object containing commands array and loading state
*/
function useSiteSwitcherCommandLoader( { search } ) {
const [ sites, setSites ] = useState( [] );
const [ isLoading, setIsLoading ] = useState( true );

// Fetch sites on mount
useEffect( () => {
fetchSitesFromWordPressCom()
.then( fetchedSites => {
setSites( fetchedSites );
setIsLoading( false );
} )
.catch( () => {
setIsLoading( false );
} );
}, [] );

// Generate and filter commands based on search term
const commands = useMemo( () => {
if ( ! sites || sites.length === 0 ) {
return [];
}

const searchLower = search ? search.toLowerCase() : '';

// Generic keywords like 'site' and 'switch site' should show all sites
const isGenericSearch =
! searchLower ||
searchLower === __( 'site', 'jetpack' ).toLowerCase() ||
searchLower === __( 'switch site', 'jetpack' ).toLowerCase();

const filteredSites = isGenericSearch
? sites
: sites.filter( site => {
const domain = getHostnameFromURL( site.URL );
return (
( site.name && site.name.toLowerCase().includes( searchLower ) ) ||
domain.toLowerCase().includes( searchLower )
);
} );

// Exclude the current site from the list
const currentURL = untrailingslashit( window.location.href.toLowerCase() );
const otherSites = filteredSites.filter( site => {
if ( ! site.URL ) {
return true;
}
// Normalize site URL for comparison
const siteURL = untrailingslashit( site.URL.toLowerCase() );
// Check if current URL starts with site URL (handles multisite subdirectory installs)
// e.g., current: example.com/site1/wp-admin matches site: example.com/site1
return ! currentURL.startsWith( siteURL );
} );

return otherSites.map( site => {
// Extract domain from URL for display - don't want to display the protocol.
const domain = getHostnameFromURL( site.URL );

const iconElement = site.icon?.img ? <img src={ site.icon.img } alt="" /> : siteLogo;

// Use site name if available, otherwise just show domain
const label = site.name
? sprintf(
/* translators: %1$s: site name, %2$s: site domain */
__( 'Switch to %1$s (%2$s)', 'jetpack' ),
site.name,
domain
)
: sprintf(
/* translators: %s: site domain */
__( 'Switch to %s', 'jetpack' ),
domain
);

return {
name: `jetpack/switch-to-site-${ domain }`,
label,
icon: iconElement,
callback: ( { close } ) => {
try {
window.location.href = new URL( '/wp-admin', site.URL ).href;
} catch {
// If URL is malformed, don't navigate
}
close();
},
keywords: [
site.name,
domain,
__( 'site', 'jetpack' ),
__( 'switch site', 'jetpack' ),
].filter( Boolean ),
};
} );
}, [ sites, search ] );

return {
commands,
isLoading,
};
}

/**
* Component that registers the site switcher command loader
*/
function JetpackSiteSwitcher() {
useCommandLoader( {
name: 'jetpack/site-switcher',
hook: useSiteSwitcherCommandLoader,
} );

return null;
}

// Render the site switcher into wp-admin
// This works with WordPress 6.9+ admin-wide command palette
if ( typeof window !== 'undefined' && window.wp && window.wp.element && window.wp.commands ) {
const { createRoot, createElement } = window.wp.element;

// Create a container for our site switcher
const container = document.createElement( 'div' );
container.id = 'jetpack-site-switcher';
container.style.display = 'none'; // Hidden, as we only need the hooks to run

// Wait for DOM to be ready
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', () => {
document.body.appendChild( container );
createRoot( container ).render( createElement( JetpackSiteSwitcher ) );
} );
} else {
document.body.appendChild( container );
createRoot( container ).render( createElement( JetpackSiteSwitcher ) );
}
}
Loading
Loading