diff --git a/src/components/publish-site-button.tsx b/src/components/publish-site-button.tsx
index 0f92b81ad3..280625c176 100644
--- a/src/components/publish-site-button.tsx
+++ b/src/components/publish-site-button.tsx
@@ -1,18 +1,22 @@
import { cloudUpload } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
-import Button from 'src/components/button';
-import { Tooltip } from 'src/components/tooltip';
+import { useCallback } from 'react';
import { useSyncSites } from 'src/hooks/sync-sites';
import { useAuth } from 'src/hooks/use-auth';
import { useContentTabs } from 'src/hooks/use-content-tabs';
import { useSiteDetails } from 'src/hooks/use-site-details';
+import { ConnectButton } from 'src/modules/sync/components/connect-button';
import { useAppDispatch } from 'src/stores';
import {
connectedSitesActions,
useGetConnectedSitesForLocalSiteQuery,
} from 'src/stores/sync/connected-sites';
-export const PublishSiteButton = () => {
+export const PublishSiteButton = ( {
+ redirectToSync = true,
+}: {
+ redirectToSync?: boolean;
+} = {} ) => {
const { __ } = useI18n();
const dispatch = useAppDispatch();
const { setSelectedTab } = useContentTabs();
@@ -24,30 +28,33 @@ export const PublishSiteButton = () => {
} );
const { isAnySitePulling, isAnySitePushing } = useSyncSites();
const isAnySiteSyncing = isAnySitePulling || isAnySitePushing;
- const handlePublishClick = () => {
- setSelectedTab( 'sync' );
+
+ const handlePublishClick = useCallback( () => {
+ if ( redirectToSync ) {
+ setSelectedTab( 'sync' );
+ }
dispatch( connectedSitesActions.openModal( 'push' ) );
- };
+ }, [ redirectToSync, setSelectedTab, dispatch ] );
- if ( connectedSites.length !== 0 ) return null;
+ // Don't show the button if there are already connected sites
+ // (only when redirectToSync is true, meaning it's used outside the sync tab)
+ if ( redirectToSync && connectedSites.length !== 0 ) return null;
return (
-
-
-
+ { __( 'Publish site' ) }
+
);
};
diff --git a/src/components/tests/app.test.tsx b/src/components/tests/app.test.tsx
index 4f4a249f8a..3072c5a228 100644
--- a/src/components/tests/app.test.tsx
+++ b/src/components/tests/app.test.tsx
@@ -12,6 +12,7 @@ import { certificateTrustApi } from 'src/stores/certificate-trust-api';
import { installedAppsApi } from 'src/stores/installed-apps-api';
import { setProviderConstants } from 'src/stores/provider-constants-slice';
import { connectedSitesApi } from 'src/stores/sync/connected-sites';
+import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites';
import { wordpressVersionsApi } from 'src/stores/wordpress-versions-api';
import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api';
@@ -90,7 +91,8 @@ describe( 'App', () => {
.concat( wpcomApi.middleware )
.concat( wpcomPublicApi.middleware )
.concat( certificateTrustApi.middleware )
- .concat( connectedSitesApi.middleware ),
+ .concat( connectedSitesApi.middleware )
+ .concat( wpcomSitesApi.middleware ),
} );
store.dispatch(
setProviderConstants( {
diff --git a/src/hooks/sync-sites/sync-sites-context.tsx b/src/hooks/sync-sites/sync-sites-context.tsx
index 5ad78dc83a..980d1b9254 100644
--- a/src/hooks/sync-sites/sync-sites-context.tsx
+++ b/src/hooks/sync-sites/sync-sites-context.tsx
@@ -10,22 +10,16 @@ import {
mapImportResponseToPushState,
} from 'src/hooks/sync-sites/use-sync-push';
import { useAuth } from 'src/hooks/use-auth';
-import { useFetchWpComSites } from 'src/hooks/use-fetch-wpcom-sites';
import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-timestamps';
-import { useSiteDetails } from 'src/hooks/use-site-details';
import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info';
import { getIpcApi } from 'src/lib/get-ipc-api';
-import {
- useGetConnectedSitesForLocalSiteQuery,
- useUpdateSiteTimestampMutation,
-} from 'src/stores/sync/connected-sites';
+import { useUpdateSiteTimestampMutation } from 'src/stores/sync/connected-sites';
import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info';
type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string;
export type SyncSitesContextType = Omit< UseSyncPull, 'pullStates' > &
- Omit< UseSyncPush, 'pushStates' > &
- ReturnType< typeof useFetchWpComSites > & {
+ Omit< UseSyncPush, 'pushStates' > & {
getLastSyncTimeText: GetLastSyncTimeText;
};
@@ -53,13 +47,6 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } )
[ formatRelativeTime ]
);
- const { selectedSite } = useSiteDetails();
- const { user } = useAuth();
- const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( {
- localSiteId: selectedSite?.id,
- userId: user?.id,
- } );
-
const [ updateSiteTimestamp ] = useUpdateSiteTimestampMutation();
const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState, cancelPull } =
@@ -79,10 +66,7 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } )
updateSiteTimestamp( { siteId: remoteSiteId, localSiteId, type: 'push' } ),
} );
- const { syncSites, isFetching, refetchSites } = useFetchWpComSites(
- connectedSites.map( ( { id } ) => id )
- );
- useListenDeepLinkConnection( { refetchSites } );
+ useListenDeepLinkConnection();
const { client } = useAuth();
const { pushStatesProgressInfo } = useSyncStatesProgressInfo();
@@ -154,9 +138,6 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } )
isSiteIdPulling,
clearPullState,
cancelPull,
- syncSites,
- refetchSites,
- isFetching,
getPullState,
getPushState,
pushSite,
diff --git a/src/hooks/sync-sites/use-listen-deep-link-connection.ts b/src/hooks/sync-sites/use-listen-deep-link-connection.ts
index b90fe6ece0..5273b64be4 100644
--- a/src/hooks/sync-sites/use-listen-deep-link-connection.ts
+++ b/src/hooks/sync-sites/use-listen-deep-link-connection.ts
@@ -1,21 +1,32 @@
-import { SyncSitesContextType } from 'src/hooks/sync-sites/sync-sites-context';
+import { useAuth } from 'src/hooks/use-auth';
import { useContentTabs } from 'src/hooks/use-content-tabs';
import { useIpcListener } from 'src/hooks/use-ipc-listener';
import { useSiteDetails } from 'src/hooks/use-site-details';
-import { useConnectSiteMutation } from 'src/stores/sync/connected-sites';
+import {
+ useConnectSiteMutation,
+ useGetConnectedSitesForLocalSiteQuery,
+} from 'src/stores/sync/connected-sites';
+import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites';
-export function useListenDeepLinkConnection( {
- refetchSites,
-}: {
- refetchSites: SyncSitesContextType[ 'refetchSites' ];
-} ) {
+export function useListenDeepLinkConnection() {
const [ connectSite ] = useConnectSiteMutation();
const { selectedSite, setSelectedSiteId } = useSiteDetails();
const { setSelectedTab, selectedTab } = useContentTabs();
+ const { user } = useAuth();
+ const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( {
+ localSiteId: selectedSite?.id,
+ userId: user?.id,
+ } );
+ const connectedSiteIds = connectedSites.map( ( { id } ) => id );
+ const { refetch: refetchWpComSites } = useGetWpComSitesQuery( {
+ connectedSiteIds,
+ userId: user?.id,
+ } );
useIpcListener( 'sync-connect-site', async ( _event, { remoteSiteId, studioSiteId } ) => {
// Fetch latest sites from network before checking
- const latestSites = await refetchSites();
+ const result = await refetchWpComSites();
+ const latestSites = result.data ?? [];
const newConnectedSite = latestSites.find( ( site ) => site.id === remoteSiteId );
if ( newConnectedSite ) {
if ( selectedSite?.id && selectedSite.id !== studioSiteId ) {
diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts
index 3908cc9f25..6965164f8b 100644
--- a/src/hooks/sync-sites/use-sync-pull.ts
+++ b/src/hooks/sync-sites/use-sync-pull.ts
@@ -20,7 +20,7 @@ import {
} from 'src/hooks/use-sync-states-progress-info';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { getHostnameFromUrl } from 'src/lib/url-utils';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import type { SyncSite } from 'src/modules/sync/types';
import type { SyncOption } from 'src/types';
export type SyncBackupState = {
diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts
index 3e0bc2cfb7..1bb778c872 100644
--- a/src/hooks/sync-sites/use-sync-push.ts
+++ b/src/hooks/sync-sites/use-sync-push.ts
@@ -17,8 +17,8 @@ import {
} from 'src/hooks/use-sync-states-progress-info';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { getHostnameFromUrl } from 'src/lib/url-utils';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info';
+import type { SyncSite } from 'src/modules/sync/types';
import type { SyncOption } from 'src/types';
export type SyncPushState = {
diff --git a/src/hooks/tests/get-sync-support.test.ts b/src/hooks/tests/get-sync-support.test.ts
index 2975280182..d912758255 100644
--- a/src/hooks/tests/get-sync-support.test.ts
+++ b/src/hooks/tests/get-sync-support.test.ts
@@ -1,4 +1,4 @@
-import { getSyncSupport } from 'src/hooks/use-fetch-wpcom-sites';
+import { getSyncSupport } from 'src/modules/sync/lib/sync-support';
// Mocks for site shapes
const baseSite = {
diff --git a/src/hooks/tests/reconcile-connected-sites.test.ts b/src/hooks/tests/reconcile-connected-sites.test.ts
index 646a2fd719..e13e6192d5 100644
--- a/src/hooks/tests/reconcile-connected-sites.test.ts
+++ b/src/hooks/tests/reconcile-connected-sites.test.ts
@@ -1,5 +1,5 @@
-import { reconcileConnectedSites } from 'src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import { reconcileConnectedSites } from 'src/modules/sync/lib/reconcile-connected-sites';
+import type { SyncSite } from 'src/modules/sync/types';
describe( 'reconcileConnectedSites', () => {
test( 'should update relevant properties', () => {
diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx
index d2b7206785..c56433daf6 100644
--- a/src/hooks/tests/use-add-site.test.tsx
+++ b/src/hooks/tests/use-add-site.test.tsx
@@ -9,7 +9,7 @@ import { useSiteDetails } from 'src/hooks/use-site-details';
import { getWordPressProvider } from 'src/lib/wordpress-provider';
import { store } from 'src/stores';
import { setProviderConstants } from 'src/stores/provider-constants-slice';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import type { SyncSite } from 'src/modules/sync/types';
jest.mock( 'src/hooks/use-site-details' );
jest.mock( 'src/hooks/use-feature-flags' );
diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts
index edc323309b..5349a088d0 100644
--- a/src/hooks/use-add-site.ts
+++ b/src/hooks/use-add-site.ts
@@ -14,7 +14,7 @@ import {
selectDefaultWordPressVersion,
} from 'src/stores/provider-constants-slice';
import { useConnectSiteMutation } from 'src/stores/sync/connected-sites';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import type { SyncSite } from 'src/modules/sync/types';
import type { Blueprint } from 'src/stores/wpcom-api';
import type { SyncOption } from 'src/types';
diff --git a/src/hooks/use-fetch-wpcom-sites/index.tsx b/src/hooks/use-fetch-wpcom-sites/index.tsx
deleted file mode 100644
index cc226545e3..0000000000
--- a/src/hooks/use-fetch-wpcom-sites/index.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-import * as Sentry from '@sentry/electron/renderer';
-import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
-import { z } from 'zod';
-import { useAuth } from 'src/hooks/use-auth';
-import { reconcileConnectedSites } from 'src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites';
-import { useOffline } from 'src/hooks/use-offline';
-import { getIpcApi } from 'src/lib/get-ipc-api';
-import type { SyncSite, SyncSupport } from 'src/hooks/use-fetch-wpcom-sites/types';
-
-const sitesEndpointSiteSchema = z.object( {
- ID: z.number(),
- is_wpcom_atomic: z.boolean(),
- name: z.string(),
- URL: z.string(),
- jetpack: z.boolean().optional(),
- is_deleted: z.boolean(),
- hosting_provider_guess: z.string().optional(),
- environment_type: z
- .enum( [ 'production', 'staging', 'development', 'sandbox', 'local' ] )
- .nullable()
- .optional(),
- is_a8c: z.boolean().optional(),
- options: z
- .object( {
- created_at: z.string(),
- wpcom_staging_blog_ids: z.array( z.number() ),
- } )
- .optional(),
- capabilities: z
- .object( {
- manage_options: z.boolean(),
- } )
- .optional(),
- plan: z
- .object( {
- expired: z.boolean().optional(),
- features: z.object( {
- active: z.array( z.string() ),
- available: z.record( z.string(), z.array( z.string() ) ).optional(),
- } ),
- is_free: z.boolean().optional(),
- product_id: z.coerce.number(),
- product_name_short: z.string(),
- product_slug: z.string(),
- user_is_owner: z.boolean().optional(),
- } )
- .optional(),
-} );
-
-type SitesEndpointSite = z.infer< typeof sitesEndpointSiteSchema >;
-
-// We use a permissive schema for the API response to fail gracefully if a single site is malformed
-const sitesEndpointResponseSchema = z.object( {
- sites: z.array( z.unknown() ),
-} );
-
-const STUDIO_SYNC_FEATURE_NAME = 'studio-sync';
-
-function isPressableSite( site: SitesEndpointSite ): boolean {
- return site.hosting_provider_guess === 'pressable';
-}
-
-function isAtomicSite( site: SitesEndpointSite ): boolean {
- return site.is_wpcom_atomic;
-}
-
-function hasSupportedPlan( site: SitesEndpointSite ): boolean {
- return site.plan?.features.active.includes( STUDIO_SYNC_FEATURE_NAME ) ?? false;
-}
-
-function isJetpackSite( site: SitesEndpointSite ): boolean {
- return !! site.jetpack && ! isAtomicSite( site ) && ! isPressableSite( site );
-}
-
-function needsTransfer( site: SitesEndpointSite ): boolean {
- return ! isJetpackSite( site ) && ! isPressableSite( site ) && ! isAtomicSite( site );
-}
-
-export function getSyncSupport( site: SitesEndpointSite, connectedSiteIds: number[] ): SyncSupport {
- if ( site.is_deleted ) {
- return 'deleted';
- }
- if ( ! site.capabilities?.manage_options ) {
- return 'missing-permissions';
- }
- if ( isJetpackSite( site ) ) {
- return 'unsupported';
- }
- if ( ! hasSupportedPlan( site ) && ! isPressableSite( site ) ) {
- return 'needs-upgrade';
- }
- if ( needsTransfer( site ) ) {
- return 'needs-transfer';
- }
- if ( connectedSiteIds.some( ( id ) => id === site.ID ) ) {
- return 'already-connected';
- }
- return 'syncable';
-}
-
-function transformSingleSiteResponse(
- site: SitesEndpointSite,
- syncSupport: SyncSupport,
- isStaging: boolean
-): SyncSite {
- return {
- id: site.ID,
- localSiteId: '',
- name: site.name,
- url: site.URL,
- isStaging,
- isPressable: isPressableSite( site ),
- environmentType: site.environment_type,
- syncSupport,
- lastPullTimestamp: null,
- lastPushTimestamp: null,
- };
-}
-
-function transformSitesResponse( sites: unknown[], connectedSiteIds: number[] ): SyncSite[] {
- const validatedSites = sites.reduce< SitesEndpointSite[] >( ( acc, rawSite ) => {
- try {
- const site = sitesEndpointSiteSchema.parse( rawSite );
- return [ ...acc, site ];
- } catch ( error ) {
- Sentry.captureException( error );
- return acc;
- }
- }, [] );
-
- const allStagingSiteIds = validatedSites.flatMap( ( site ) => {
- return site.options?.wpcom_staging_blog_ids ?? [];
- } );
-
- return validatedSites
- .filter( ( site ) => ! site.is_a8c )
- .filter( ( site ) => ! site.is_deleted || connectedSiteIds.some( ( id ) => id === site.ID ) )
- .map( ( site ) => {
- // The API returns the wrong value for the `is_wpcom_staging_site` prop while staging sites
- // are being created. Hence the check in other sites' `wpcom_staging_blog_ids` arrays.
- const isStaging = allStagingSiteIds.includes( site.ID );
- const syncSupport = getSyncSupport( site, connectedSiteIds );
-
- return transformSingleSiteResponse( site, syncSupport, isStaging );
- } );
-}
-
-type FetchSites = () => Promise< SyncSite[] >;
-
-export const useFetchWpComSites = ( connectedSiteIdsOnlyForSelectedSite: number[] ) => {
- const [ rawSyncSites, setRawSyncSites ] = useState< unknown[] >( [] );
- const { isAuthenticated, client } = useAuth();
- const isFetchingSites = useRef( false );
- const isOffline = useOffline();
-
- const joinedConnectedSiteIds = connectedSiteIdsOnlyForSelectedSite.join( ',' );
- // we need this trick to avoid unnecessary re-renders,
- // as a result different instances of the same array don't trigger refetching
- const memoizedConnectedSiteIds: number[] = useMemo(
- () =>
- joinedConnectedSiteIds
- ? joinedConnectedSiteIds.split( ',' ).map( ( id ) => parseInt( id, 10 ) )
- : [],
- [ joinedConnectedSiteIds ]
- );
-
- const fetchSites = useCallback< FetchSites >( async () => {
- if ( ! client?.req || isFetchingSites.current || ! isAuthenticated || isOffline ) {
- return [];
- }
-
- isFetchingSites.current = true;
-
- try {
- const allConnectedSites = await getIpcApi().getConnectedWpcomSites();
-
- const fields = [
- 'name',
- 'ID',
- 'URL',
- 'plan',
- 'capabilities',
- 'is_wpcom_atomic',
- 'options',
- 'jetpack',
- 'is_deleted',
- 'is_a8c',
- 'hosting_provider_guess',
- 'environment_type',
- ].join( ',' );
-
- const response = await client.req.get(
- {
- apiNamespace: 'rest/v1.2',
- path: `/me/sites`,
- },
- {
- fields,
- filter: 'atomic,wpcom',
- options: 'created_at,wpcom_staging_blog_ids',
- site_activity: 'active',
- }
- );
-
- const parsedResponse = sitesEndpointResponseSchema.parse( response );
- const syncSites = transformSitesResponse(
- parsedResponse.sites,
- allConnectedSites.map( ( { id } ) => id )
- );
-
- // whenever array of syncSites changes, we need to update connectedSites to keep them updated with wordpress.com
- const { updatedConnectedSites } = reconcileConnectedSites( allConnectedSites, syncSites );
- await getIpcApi().updateConnectedWpcomSites( updatedConnectedSites );
-
- setRawSyncSites( parsedResponse.sites );
-
- return syncSites;
- } catch ( error ) {
- Sentry.captureException( error );
- console.error( error );
- return [];
- } finally {
- isFetchingSites.current = false;
- }
- }, [ client?.req, isAuthenticated, isOffline ] );
-
- useEffect( () => {
- void fetchSites();
- }, [ fetchSites ] );
-
- const syncSitesWithSyncSupportForSelectedSite = useMemo(
- () => transformSitesResponse( rawSyncSites, memoizedConnectedSiteIds ),
- [ rawSyncSites, memoizedConnectedSiteIds ]
- );
-
- return {
- syncSites: syncSitesWithSyncSupportForSelectedSite,
- isFetching: isFetchingSites.current,
- refetchSites: fetchSites,
- };
-};
diff --git a/src/hooks/use-fetch-wpcom-sites/types.ts b/src/hooks/use-fetch-wpcom-sites/types.ts
deleted file mode 100644
index e6e58d080f..0000000000
--- a/src/hooks/use-fetch-wpcom-sites/types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export type SyncSupport =
- | 'unsupported'
- | 'syncable'
- | 'needs-transfer'
- | 'already-connected'
- | 'needs-upgrade'
- | 'deleted'
- | 'missing-permissions';
-
-export type SyncSite = {
- id: number;
- localSiteId: string;
- name: string;
- url: string;
- isStaging: boolean;
- isPressable: boolean;
- environmentType?: string | null;
- syncSupport: SyncSupport;
- lastPullTimestamp: string | null;
- lastPushTimestamp: string | null;
-};
diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts
index 98497c1d82..e186f6dc93 100644
--- a/src/lib/feature-flags.ts
+++ b/src/lib/feature-flags.ts
@@ -16,7 +16,7 @@ export const FEATURE_FLAGS_DEFINITION: Record< keyof FeatureFlags, FeatureFlagDe
label: 'Streamline Onboarding',
env: 'STREAMLINE_ONBOARDING',
flag: 'streamlineOnboarding',
- default: false,
+ default: true,
},
} as const;
diff --git a/src/modules/add-site/components/pull-remote-site.tsx b/src/modules/add-site/components/pull-remote-site.tsx
index 3fca50cfec..fd2a1e6e54 100644
--- a/src/modules/add-site/components/pull-remote-site.tsx
+++ b/src/modules/add-site/components/pull-remote-site.tsx
@@ -1,22 +1,23 @@
import {
- useNavigator,
__experimentalHeading as Heading,
__experimentalVStack as VStack,
} from '@wordpress/components';
import { check, Icon } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
-import { PropsWithChildren, useEffect, useState } from 'react';
+import { PropsWithChildren, useState } from 'react';
import { ArrowIcon } from 'src/components/arrow-icon';
import Button from 'src/components/button';
import offlineIcon from 'src/components/offline-icon';
import { Tooltip } from 'src/components/tooltip';
-import { useSyncSites } from 'src/hooks/sync-sites';
import { useAuth } from 'src/hooks/use-auth';
import { useOffline } from 'src/hooks/use-offline';
+import { useSiteDetails } from 'src/hooks/use-site-details';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { ListSites, SearchSites } from 'src/modules/sync/components/sync-sites-modal-selector';
import { SyncTabImage } from 'src/modules/sync/components/sync-tab-image';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites';
+import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites';
+import type { SyncSite } from 'src/modules/sync/types';
function SiteSyncDescription( { children }: PropsWithChildren ) {
const { __ } = useI18n();
@@ -118,9 +119,22 @@ export function PullRemoteSite( {
setSelectedRemoteSite: ( site?: SyncSite ) => void;
} ) {
const { __ } = useI18n();
- const { isAuthenticated } = useAuth();
- const { location } = useNavigator();
- const { syncSites, refetchSites } = useSyncSites();
+ const { isAuthenticated, user } = useAuth();
+
+ const { selectedSite } = useSiteDetails();
+ const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( {
+ localSiteId: selectedSite?.id,
+ userId: user?.id,
+ } );
+ const connectedSiteIds = connectedSites.map( ( { id } ) => id );
+ const { data: syncSites = [] } = useGetWpComSitesQuery(
+ {
+ connectedSiteIds,
+ userId: user?.id,
+ },
+ { refetchOnMountOrArgChange: true }
+ );
+
const [ searchQuery, setSearchQuery ] = useState< string >( '' );
const filteredSites = syncSites.filter( ( site ) => {
@@ -131,12 +145,6 @@ export function PullRemoteSite( {
);
} );
- useEffect( () => {
- if ( location.path === '/pullRemote' && isAuthenticated ) {
- void refetchSites();
- }
- }, [ location.path, isAuthenticated, refetchSites ] );
-
const handleSiteSelect = ( siteId: number ) => {
const site = syncSites.find( ( s ) => s.id === siteId );
setSelectedRemoteSite( site );
diff --git a/src/modules/add-site/index.tsx b/src/modules/add-site/index.tsx
index 64d98ec05f..7dc12b7a7e 100644
--- a/src/modules/add-site/index.tsx
+++ b/src/modules/add-site/index.tsx
@@ -7,11 +7,11 @@ import { BlueprintValidationWarning } from 'common/lib/blueprint-validation';
import Button from 'src/components/button';
import { FullscreenModal } from 'src/components/fullscreen-modal';
import { useAddSite } from 'src/hooks/use-add-site';
-import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
import { useImportExport } from 'src/hooks/use-import-export';
import { useIpcListener } from 'src/hooks/use-ipc-listener';
import { generateSiteName } from 'src/lib/generate-site-name';
import { getIpcApi } from 'src/lib/get-ipc-api';
+import { SyncSite } from 'src/modules/sync/types';
import { useRootSelector } from 'src/stores';
import { formatRtkError } from 'src/stores/format-rtk-error';
import {
diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx
index 172116b438..218d3044bc 100644
--- a/src/modules/sync/components/sync-connected-sites.tsx
+++ b/src/modules/sync/components/sync-connected-sites.tsx
@@ -34,7 +34,7 @@ import {
connectedSitesActions,
useGetConnectedSitesForLocalSiteQuery,
} from 'src/stores/sync/connected-sites';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import type { SyncSite } from 'src/modules/sync/types';
const SyncConnectedSiteControls = ( {
connectedSite,
diff --git a/src/modules/sync/components/sync-dialog.tsx b/src/modules/sync/components/sync-dialog.tsx
index 29bf9b5fca..1ead8966ef 100644
--- a/src/modules/sync/components/sync-dialog.tsx
+++ b/src/modules/sync/components/sync-dialog.tsx
@@ -27,7 +27,7 @@ import { useI18nLocale } from 'src/stores';
import { useLatestRewindId, useRemoteFileTree, useLocalFileTree } from 'src/stores/sync';
import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api';
import { TreeViewLoadingSkeleton } from './tree-view-loading-skeleton';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import type { SyncSite } from 'src/modules/sync/types';
type SyncDialogProps = {
type: 'push' | 'pull';
diff --git a/src/modules/sync/components/sync-sites-modal-selector.tsx b/src/modules/sync/components/sync-sites-modal-selector.tsx
index 5495feb9a9..bae8d4ba0a 100644
--- a/src/modules/sync/components/sync-sites-modal-selector.tsx
+++ b/src/modules/sync/components/sync-sites-modal-selector.tsx
@@ -8,16 +8,19 @@ import Modal from 'src/components/modal';
import offlineIcon from 'src/components/offline-icon';
import { PressableLogo } from 'src/components/pressable-logo';
import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle';
+import { useAuth } from 'src/hooks/use-auth';
import { useOffline } from 'src/hooks/use-offline';
import { cx } from 'src/lib/cx';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { getLocalizedLink } from 'src/lib/get-localized-link';
import { CreateButton } from 'src/modules/sync/components/create-button';
import { EnvironmentBadge } from 'src/modules/sync/components/environment-badge';
+import { NoWpcomSitesModal } from 'src/modules/sync/components/no-wpcom-sites-modal';
import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils';
import { useI18nLocale } from 'src/stores';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
-import type { SyncModalMode } from 'src/modules/sync/types';
+import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites';
+import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites';
+import type { SyncSite, SyncModalMode } from 'src/modules/sync/types';
const SearchControl = process.env.NODE_ENV === 'test' ? () => null : SearchControlWp;
@@ -27,26 +30,37 @@ const focusConnectButton = () => {
};
export function SyncSitesModalSelector( {
- isLoading,
onRequestClose,
onConnect,
- syncSites,
- onInitialRender,
selectedSite,
mode = 'connect',
}: {
- isLoading?: boolean;
onRequestClose: () => void;
- syncSites: SyncSite[];
onConnect: ( siteId: number ) => void;
- onInitialRender?: () => void;
selectedSite: SiteDetails;
mode?: SyncModalMode;
} ) {
const { __ } = useI18n();
+ const { user } = useAuth();
const [ selectedSiteId, setSelectedSiteId ] = useState< number | null >( null );
const [ searchQuery, setSearchQuery ] = useState< string >( '' );
const isOffline = useOffline();
+
+ const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( {
+ localSiteId: selectedSite.id,
+ userId: user?.id,
+ } );
+ const connectedSiteIds = connectedSites.map( ( { id } ) => id );
+
+ const {
+ data: syncSites = [],
+ isLoading,
+ isSuccess,
+ } = useGetWpComSitesQuery(
+ { connectedSiteIds, userId: user?.id },
+ { refetchOnMountOrArgChange: true }
+ );
+
const filteredSites = syncSites.filter( ( site ) => {
const searchQueryLower = searchQuery.toLowerCase();
return (
@@ -56,6 +70,10 @@ export function SyncSitesModalSelector( {
} );
const isEmpty = filteredSites.length === 0;
+ if ( syncSites.length === 0 && isSuccess && ! isLoading ) {
+ return ;
+ }
+
const getModalTitle = () => {
switch ( mode ) {
case 'push':
@@ -68,12 +86,6 @@ export function SyncSitesModalSelector( {
}
};
- useEffect( () => {
- if ( onInitialRender ) {
- onInitialRender();
- }
- }, [ onInitialRender ] );
-
return (
id ) );
const [ connectSite ] = useConnectSiteMutation();
const [ disconnectSite ] = useDisconnectSiteMutation();
const { pushSite, pullSite, isAnySitePulling, isAnySitePushing } = useSyncSites();
+
+ const connectedSiteIds = connectedSites.map( ( { id } ) => id );
+ const { data: syncSites = [] } = useGetWpComSitesQuery( {
+ connectedSiteIds,
+ userId: user?.id,
+ } );
+
const isAnySiteSyncing = isAnySitePulling || isAnySitePushing;
const { streamlineOnboarding } = useFeatureFlags();
const [ selectedRemoteSite, setSelectedRemoteSite ] = useState< SyncSite | null >( null );
- const [ pendingModalMode, setPendingModalMode ] = useState< 'push' | 'pull' | null >( null );
-
- // Open modal after fetch completes and state updates
- useEffect( () => {
- if ( pendingModalMode && ! isFetchingSyncSites ) {
- dispatch( connectedSitesActions.openModal( pendingModalMode ) );
- setPendingModalMode( null );
- }
- }, [ pendingModalMode, isFetchingSyncSites, syncSites.length, dispatch ] );
if ( ! isAuthenticated ) {
return ;
@@ -173,7 +165,7 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
}
};
- const handleSiteSelection = async ( siteId: number, mode: SyncModalMode | null ) => {
+ const handleSiteSelection = async ( siteId: number ) => {
const selectedSiteFromList = syncSites.find( ( site ) => site.id === siteId );
if ( ! selectedSiteFromList ) {
getIpcApi().showErrorMessageBox( {
@@ -183,8 +175,8 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
return;
}
- if ( mode === 'push' || mode === 'pull' ) {
- dispatch( connectedSitesActions.openModal( mode ) );
+ if ( reduxModalMode === 'push' || reduxModalMode === 'pull' ) {
+ dispatch( connectedSitesActions.openModal( reduxModalMode ) );
setSelectedRemoteSite( selectedSiteFromList );
} else {
await handleConnect( selectedSiteFromList );
@@ -216,34 +208,14 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
{ streamlineOnboarding ? (
-
setPendingModalMode( 'push' ) }
- disabled={ isAnySiteSyncing || pendingModalMode !== null }
- isBusy={ pendingModalMode === 'push' }
- tooltipText={
- pendingModalMode === 'pull'
- ? __( 'Please wait for the current operation to finish.' )
- : isAnySiteSyncing
- ? __(
- 'Another site is syncing. Please wait for the sync to finish before you publish your site.'
- )
- : __( 'Publishing your site requires an internet connection.' )
- }
- >
- { __( 'Publish site' ) }
-
+
setPendingModalMode( 'pull' ) }
+ connectSite={ () => dispatch( connectedSitesActions.openModal( 'pull' ) ) }
className={ isAnySiteSyncing ? '' : '!text-a8c-blue-50 !shadow-a8c-blue-50' }
- disabled={ isAnySiteSyncing || pendingModalMode !== null }
- isBusy={ pendingModalMode === 'pull' }
+ disabled={ isAnySiteSyncing }
tooltipText={
- pendingModalMode === 'push'
- ? __( 'Please wait for the current operation to finish.' )
- : isAnySiteSyncing
+ isAnySiteSyncing
? __(
'Another site is syncing. Please wait for the sync to finish before you pull a site.'
)
@@ -267,44 +239,16 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
) }
{ isModalOpen && (
- <>
- { reduxModalMode === 'connect' ? (
- {
- dispatch( connectedSitesActions.closeModal() );
- } }
- syncSites={ syncSites }
- onInitialRender={ refetchSites }
- onConnect={ async ( siteId: number ) => {
- await handleSiteSelection( siteId, reduxModalMode );
- } }
- selectedSite={ selectedSite }
- />
- ) : syncSites.length === 0 && ! isFetchingSyncSites ? (
- {
- dispatch( connectedSitesActions.closeModal() );
- } }
- selectedSite={ selectedSite }
- />
- ) : (
- {
- dispatch( connectedSitesActions.closeModal() );
- } }
- syncSites={ syncSites }
- onInitialRender={ refetchSites }
- onConnect={ async ( siteId: number ) => {
- await handleSiteSelection( siteId, reduxModalMode );
- } }
- selectedSite={ selectedSite }
- />
- ) }
- >
+ {
+ dispatch( connectedSitesActions.closeModal() );
+ } }
+ onConnect={ async ( siteId: number ) => {
+ await handleSiteSelection( siteId );
+ } }
+ selectedSite={ selectedSite }
+ />
) }
{ reduxModalMode && reduxModalMode !== 'connect' && selectedRemoteSite && (
diff --git a/src/modules/sync/lib/environment-utils.ts b/src/modules/sync/lib/environment-utils.ts
index 47cb4499d3..bd30a8488a 100644
--- a/src/modules/sync/lib/environment-utils.ts
+++ b/src/modules/sync/lib/environment-utils.ts
@@ -1,6 +1,6 @@
import { __ } from '@wordpress/i18n';
import { z } from 'zod';
-import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import { SyncSite } from 'src/modules/sync/types';
const EnvironmentSchema = z.enum( [ 'production', 'staging', 'development' ] );
export type EnvironmentType = z.infer< typeof EnvironmentSchema >;
diff --git a/src/modules/sync/lib/ipc-handlers.ts b/src/modules/sync/lib/ipc-handlers.ts
index 3a93cdb268..956d9764f8 100644
--- a/src/modules/sync/lib/ipc-handlers.ts
+++ b/src/modules/sync/lib/ipc-handlers.ts
@@ -5,7 +5,6 @@ import path from 'node:path';
import * as Sentry from '@sentry/electron/main';
import { z } from 'zod';
import { isErrnoException } from 'common/lib/is-errno-exception';
-import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
import {
PullStateProgressInfo,
PushStateProgressInfo,
@@ -19,6 +18,7 @@ import { getAuthenticationToken } from 'src/lib/oauth';
import { keepSqliteIntegrationUpdated } from 'src/lib/sqlite-versions';
import wpcomFactory from 'src/lib/wpcom-factory';
import wpcomXhrRequest from 'src/lib/wpcom-xhr-request-factory';
+import { SyncSite } from 'src/modules/sync/types';
import { SiteServer } from 'src/site-server';
import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data';
import { SyncOption } from 'src/types';
diff --git a/src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx b/src/modules/sync/lib/reconcile-connected-sites.tsx
similarity index 92%
rename from src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx
rename to src/modules/sync/lib/reconcile-connected-sites.tsx
index 18dafaff4e..faea53b67d 100644
--- a/src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx
+++ b/src/modules/sync/lib/reconcile-connected-sites.tsx
@@ -1,4 +1,4 @@
-import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import { SyncSite } from 'src/modules/sync/types';
/**
* Generate updated site data to be stored in `appdata-v1.json`:
diff --git a/src/modules/sync/lib/sync-support.ts b/src/modules/sync/lib/sync-support.ts
new file mode 100644
index 0000000000..82aa373e9c
--- /dev/null
+++ b/src/modules/sync/lib/sync-support.ts
@@ -0,0 +1,46 @@
+import type { SyncSupport } from 'src/modules/sync/types';
+import type { SitesEndpointSite } from 'src/stores/sync/wpcom-sites';
+
+const STUDIO_SYNC_FEATURE_NAME = 'studio-sync';
+
+export function isPressableSite( site: SitesEndpointSite ): boolean {
+ return site.hosting_provider_guess === 'pressable';
+}
+
+export function isAtomicSite( site: SitesEndpointSite ): boolean {
+ return site.is_wpcom_atomic;
+}
+
+export function hasSupportedPlan( site: SitesEndpointSite ): boolean {
+ return site.plan?.features.active.includes( STUDIO_SYNC_FEATURE_NAME ) ?? false;
+}
+
+export function isJetpackSite( site: SitesEndpointSite ): boolean {
+ return !! site.jetpack && ! isAtomicSite( site ) && ! isPressableSite( site );
+}
+
+export function needsTransfer( site: SitesEndpointSite ): boolean {
+ return ! isJetpackSite( site ) && ! isPressableSite( site ) && ! isAtomicSite( site );
+}
+
+export function getSyncSupport( site: SitesEndpointSite, connectedSiteIds: number[] ): SyncSupport {
+ if ( site.is_deleted ) {
+ return 'deleted';
+ }
+ if ( ! site.capabilities?.manage_options ) {
+ return 'missing-permissions';
+ }
+ if ( isJetpackSite( site ) ) {
+ return 'unsupported';
+ }
+ if ( ! hasSupportedPlan( site ) && ! isPressableSite( site ) ) {
+ return 'needs-upgrade';
+ }
+ if ( needsTransfer( site ) ) {
+ return 'needs-transfer';
+ }
+ if ( connectedSiteIds.some( ( id ) => id === site.ID ) ) {
+ return 'already-connected';
+ }
+ return 'syncable';
+}
diff --git a/src/modules/sync/tests/index.test.tsx b/src/modules/sync/tests/index.test.tsx
index 98726d3eea..707d5b3e04 100644
--- a/src/modules/sync/tests/index.test.tsx
+++ b/src/modules/sync/tests/index.test.tsx
@@ -6,20 +6,23 @@ import { SyncPushState } from 'src/hooks/sync-sites/use-sync-push';
import { useAuth } from 'src/hooks/use-auth';
import { ContentTabsProvider } from 'src/hooks/use-content-tabs';
import { useFeatureFlags } from 'src/hooks/use-feature-flags';
-import { useFetchWpComSites } from 'src/hooks/use-fetch-wpcom-sites';
-import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { ContentTabSync } from 'src/modules/sync';
import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size';
+import { SyncSite } from 'src/modules/sync/types';
import { store } from 'src/stores';
import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync';
+import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites';
import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer';
store.replaceReducer( testReducer );
jest.mock( 'src/lib/get-ipc-api' );
jest.mock( 'src/hooks/use-auth' );
-jest.mock( 'src/hooks/use-fetch-wpcom-sites' );
+jest.mock( 'src/stores/sync/wpcom-sites', () => ( {
+ ...jest.requireActual( 'src/stores/sync/wpcom-sites' ),
+ useGetWpComSitesQuery: jest.fn(),
+} ) );
jest.mock( 'src/hooks/use-feature-flags' );
jest.mock( 'src/hooks/sync-sites/sync-sites-context', () => ( {
...jest.requireActual( '../../../hooks/sync-sites/sync-sites-context' ),
@@ -115,10 +118,11 @@ describe( 'ContentTabSync', () => {
const currentMock = ( getIpcApi as jest.Mock )();
currentMock.getConnectedWpcomSites.mockResolvedValue( connectedSites );
- ( useFetchWpComSites as jest.Mock ).mockReturnValue( {
- syncSites,
+ ( useGetWpComSitesQuery as jest.Mock ).mockReturnValue( {
+ data: syncSites,
+ isLoading: false,
isFetching: false,
- refetchSites: jest.fn(),
+ refetch: jest.fn().mockResolvedValue( { data: syncSites } ),
} );
};
@@ -179,10 +183,11 @@ describe( 'ContentTabSync', () => {
error: null,
} );
- ( useFetchWpComSites as jest.Mock ).mockReturnValue( {
- syncSites: [],
+ ( useGetWpComSitesQuery as jest.Mock ).mockReturnValue( {
+ data: [],
+ isLoading: false,
isFetching: false,
- refetchSites: jest.fn(),
+ refetch: jest.fn().mockResolvedValue( { data: [] } ),
} );
( useRemoteFileTree as jest.Mock ).mockReturnValue( {
diff --git a/src/modules/sync/types.ts b/src/modules/sync/types.ts
index e921c51b4f..f2ea55a873 100644
--- a/src/modules/sync/types.ts
+++ b/src/modules/sync/types.ts
@@ -6,3 +6,25 @@ export type RawDirectoryEntry = {
};
export type SyncModalMode = 'push' | 'pull' | 'connect';
+
+export type SyncSupport =
+ | 'unsupported'
+ | 'syncable'
+ | 'needs-transfer'
+ | 'already-connected'
+ | 'needs-upgrade'
+ | 'deleted'
+ | 'missing-permissions';
+
+export type SyncSite = {
+ id: number;
+ localSiteId: string;
+ name: string;
+ url: string;
+ isStaging: boolean;
+ isPressable: boolean;
+ environmentType?: string | null;
+ syncSupport: SyncSupport;
+ lastPullTimestamp: string | null;
+ lastPushTimestamp: string | null;
+};
diff --git a/src/storage/storage-types.ts b/src/storage/storage-types.ts
index 246c47817f..70d2e52f6c 100644
--- a/src/storage/storage-types.ts
+++ b/src/storage/storage-types.ts
@@ -1,7 +1,7 @@
import { Snapshot } from 'common/types/snapshot';
import { StoredToken } from 'src/lib/oauth';
import { SupportedEditor } from 'src/modules/user-settings/lib/editor';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
+import type { SyncSite } from 'src/modules/sync/types';
import type { SupportedTerminal } from 'src/modules/user-settings/lib/terminal';
export interface WindowBounds {
diff --git a/src/stores/index.ts b/src/stores/index.ts
index 708e06ccd1..72cb2477c2 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -25,6 +25,7 @@ import {
} from 'src/stores/snapshot-slice';
import { syncReducer } from 'src/stores/sync';
import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites';
+import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites';
import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api';
import { wordpressVersionsApi } from './wordpress-versions-api';
import type { SupportedLocale } from 'common/lib/locale';
@@ -40,6 +41,7 @@ export type RootState = {
sync: ReturnType< typeof syncReducer >;
connectedSitesApi: ReturnType< typeof connectedSitesApi.reducer >;
connectedSites: ReturnType< typeof connectedSitesReducer >;
+ wpcomSitesApi: ReturnType< typeof wpcomSitesApi.reducer >;
wordpressVersionsApi: ReturnType< typeof wordpressVersionsApi.reducer >;
wpcomApi: ReturnType< typeof wpcomApi.reducer >;
wpcomPublicApi: ReturnType< typeof wpcomPublicApi.reducer >;
@@ -93,6 +95,7 @@ export const rootReducer = combineReducers( {
installedAppsApi: installedAppsApi.reducer,
connectedSitesApi: connectedSitesApi.reducer,
connectedSites: connectedSitesReducer,
+ wpcomSitesApi: wpcomSitesApi.reducer,
onboarding: onboardingReducer,
providerConstants: providerConstantsReducer,
snapshot: snapshotReducer,
@@ -112,6 +115,7 @@ export const store = configureStore( {
.concat( appVersionApi.middleware )
.concat( installedAppsApi.middleware )
.concat( connectedSitesApi.middleware )
+ .concat( wpcomSitesApi.middleware )
.concat( wordpressVersionsApi.middleware )
.concat( wpcomApi.middleware )
.concat( wpcomPublicApi.middleware )
diff --git a/src/stores/sync/connected-sites.ts b/src/stores/sync/connected-sites.ts
index 20a49ededc..1be946e232 100644
--- a/src/stores/sync/connected-sites.ts
+++ b/src/stores/sync/connected-sites.ts
@@ -2,8 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { RootState } from 'src/stores';
-import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types';
-import type { SyncModalMode } from 'src/modules/sync/types';
+import type { SyncSite, SyncModalMode } from 'src/modules/sync/types';
type ConnectedSitesState = {
isModalOpen: boolean;
diff --git a/src/stores/sync/wpcom-sites.ts b/src/stores/sync/wpcom-sites.ts
new file mode 100644
index 0000000000..3719ced118
--- /dev/null
+++ b/src/stores/sync/wpcom-sites.ts
@@ -0,0 +1,197 @@
+import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
+import * as Sentry from '@sentry/electron/renderer';
+import { z } from 'zod';
+import { getIpcApi } from 'src/lib/get-ipc-api';
+import { reconcileConnectedSites } from 'src/modules/sync/lib/reconcile-connected-sites';
+import { getSyncSupport, isPressableSite } from 'src/modules/sync/lib/sync-support';
+import { withOfflineCheck } from 'src/stores/utils/with-offline-check';
+import { getWpcomClient } from 'src/stores/wpcom-api';
+import type { SyncSite, SyncSupport } from 'src/modules/sync/types';
+
+// Schema for WordPress.com sites endpoint
+const sitesEndpointSiteSchema = z.object( {
+ ID: z.number(),
+ is_wpcom_atomic: z.boolean(),
+ name: z.string(),
+ URL: z.string(),
+ jetpack: z.boolean().optional(),
+ is_deleted: z.boolean(),
+ hosting_provider_guess: z.string().optional(),
+ environment_type: z
+ .enum( [ 'production', 'staging', 'development', 'sandbox', 'local' ] )
+ .nullable()
+ .optional(),
+ is_a8c: z.boolean().optional(),
+ options: z
+ .object( {
+ created_at: z.string(),
+ wpcom_staging_blog_ids: z.array( z.number() ),
+ } )
+ .optional(),
+ capabilities: z
+ .object( {
+ manage_options: z.boolean(),
+ } )
+ .optional(),
+ plan: z
+ .object( {
+ expired: z.boolean().optional(),
+ features: z.object( {
+ active: z.array( z.string() ),
+ available: z.record( z.string(), z.array( z.string() ) ).optional(),
+ } ),
+ is_free: z.boolean().optional(),
+ product_id: z.coerce.number(),
+ product_name_short: z.string(),
+ product_slug: z.string(),
+ user_is_owner: z.boolean().optional(),
+ } )
+ .optional(),
+} );
+
+export type SitesEndpointSite = z.infer< typeof sitesEndpointSiteSchema >;
+
+// We use a permissive schema for the API response to fail gracefully if a single site is malformed
+const sitesEndpointResponseSchema = z.object( {
+ sites: z.array( z.unknown() ),
+} );
+
+function transformSingleSiteResponse(
+ site: SitesEndpointSite,
+ syncSupport: SyncSupport,
+ isStaging: boolean
+): SyncSite {
+ return {
+ id: site.ID,
+ localSiteId: '',
+ name: site.name,
+ url: site.URL,
+ isStaging,
+ isPressable: isPressableSite( site ),
+ environmentType: site.environment_type,
+ syncSupport,
+ lastPullTimestamp: null,
+ lastPushTimestamp: null,
+ };
+}
+
+/**
+ * Transforms the WordPress.com sites API response into SyncSite objects.
+ *
+ * @param sites - Raw site data from the WordPress.com API
+ * @param connectedSiteIds - IDs of sites already connected to the current local site.
+ * Used to: 1) keep deleted sites in the list if they're connected, and
+ * 2) determine sync support status (already-connected vs syncable)
+ */
+function transformSitesResponse( sites: unknown[], connectedSiteIds: number[] ): SyncSite[] {
+ const validatedSites = sites.reduce< SitesEndpointSite[] >( ( acc, rawSite ) => {
+ try {
+ const site = sitesEndpointSiteSchema.parse( rawSite );
+ return [ ...acc, site ];
+ } catch ( error ) {
+ Sentry.captureException( error );
+ return acc;
+ }
+ }, [] );
+
+ const allStagingSiteIds = validatedSites.flatMap( ( site ) => {
+ return site.options?.wpcom_staging_blog_ids ?? [];
+ } );
+
+ return validatedSites
+ .filter( ( site ) => ! site.is_a8c )
+ .filter( ( site ) => ! site.is_deleted || connectedSiteIds.some( ( id ) => id === site.ID ) )
+ .map( ( site ) => {
+ // The API returns the wrong value for the `is_wpcom_staging_site` prop while staging sites
+ // are being created. Hence the check in other sites' `wpcom_staging_blog_ids` arrays.
+ const isStaging = allStagingSiteIds.includes( site.ID );
+ const syncSupport = getSyncSupport( site, connectedSiteIds );
+
+ return transformSingleSiteResponse( site, syncSupport, isStaging );
+ } );
+}
+
+export const wpcomSitesApi = createApi( {
+ reducerPath: 'wpcomSitesApi',
+ baseQuery: fetchBaseQuery(),
+ tagTypes: [ 'WpComSites' ],
+ endpoints: ( builder ) => ( {
+ getWpComSites: builder.query< SyncSite[], { connectedSiteIds: number[]; userId?: number } >( {
+ queryFn: async ( { connectedSiteIds } ) => {
+ const wpcomClient = getWpcomClient();
+ if ( ! wpcomClient ) {
+ return { error: { status: 401, data: 'Not authenticated' } };
+ }
+
+ try {
+ const allConnectedSites = await getIpcApi().getConnectedWpcomSites();
+
+ const fields = [
+ 'name',
+ 'ID',
+ 'URL',
+ 'plan',
+ 'capabilities',
+ 'is_wpcom_atomic',
+ 'options',
+ 'jetpack',
+ 'is_deleted',
+ 'is_a8c',
+ 'hosting_provider_guess',
+ 'environment_type',
+ ].join( ',' );
+
+ const response = await wpcomClient.req.get(
+ {
+ apiNamespace: 'rest/v1.2',
+ path: `/me/sites`,
+ },
+ {
+ fields,
+ filter: 'atomic,wpcom',
+ options: 'created_at,wpcom_staging_blog_ids',
+ site_activity: 'active',
+ }
+ );
+
+ const parsedResponse = sitesEndpointResponseSchema.parse( response );
+
+ const syncSitesForReconciliation = transformSitesResponse(
+ parsedResponse.sites,
+ allConnectedSites.map( ( { id } ) => id )
+ );
+
+ const { updatedConnectedSites } = reconcileConnectedSites(
+ allConnectedSites,
+ syncSitesForReconciliation
+ );
+ await getIpcApi().updateConnectedWpcomSites( updatedConnectedSites );
+
+ const syncSitesForSelectedSite = transformSitesResponse(
+ parsedResponse.sites,
+ connectedSiteIds
+ );
+
+ return { data: syncSitesForSelectedSite };
+ } catch ( error ) {
+ Sentry.captureException( error );
+ console.error( error );
+ return {
+ error: {
+ status: 500,
+ data: error,
+ },
+ };
+ }
+ },
+ providesTags: ( _result, _error, arg ) => [ { type: 'WpComSites', userId: arg.userId } ],
+ keepUnusedDataFor: 60,
+ } ),
+ } ),
+} );
+
+const { useGetWpComSitesQuery: useGetWpComSitesQueryBase } = wpcomSitesApi;
+
+// Wrap the query hook with offline check
+// Authentication is already handled in queryFn which checks wpcomClient
+export const useGetWpComSitesQuery = withOfflineCheck( useGetWpComSitesQueryBase );
diff --git a/src/stores/wpcom-api.ts b/src/stores/wpcom-api.ts
index 1f3e390de9..398ba34c93 100644
--- a/src/stores/wpcom-api.ts
+++ b/src/stores/wpcom-api.ts
@@ -77,6 +77,10 @@ export const setWpcomClient = ( client: WPCOM | undefined ) => {
wpcomClient = client;
};
+export const getWpcomClient = (): WPCOM | undefined => {
+ return wpcomClient;
+};
+
const wpcomBaseQuery: BaseQueryFn<
{ path: string; apiNamespace?: string },
unknown,