diff --git a/src/components/content-tab-sync.tsx b/src/components/content-tab-sync.tsx index 20d271880..16f3a40aa 100644 --- a/src/components/content-tab-sync.tsx +++ b/src/components/content-tab-sync.tsx @@ -4,7 +4,6 @@ import { PropsWithChildren, useState, useEffect } from 'react'; import { CLIENT_ID, PROTOCOL_PREFIX, SCOPES, WP_AUTHORIZE_ENDPOINT } from '../constants'; import { useSyncSites } from '../hooks/sync-sites'; import { useAuth } from '../hooks/use-auth'; -import { SyncSite } from '../hooks/use-fetch-wpcom-sites'; import { useOffline } from '../hooks/use-offline'; import { getIpcApi } from '../lib/get-ipc-api'; import { ArrowIcon } from './arrow-icon'; @@ -16,6 +15,7 @@ import { SyncSitesModalSelector } from './sync-sites-modal-selector'; import { SyncTabImage } from './sync-tab-image'; import { Tooltip } from './tooltip'; import { WordPressShortLogo } from './wordpress-short-logo'; +import type { SyncSite } from '../hooks/use-fetch-wpcom-sites/types'; function SiteSyncDescription( { children }: PropsWithChildren ) { const { __ } = useI18n(); diff --git a/src/components/sync-connected-sites.tsx b/src/components/sync-connected-sites.tsx index e108ac767..e08fc833f 100644 --- a/src/components/sync-connected-sites.tsx +++ b/src/components/sync-connected-sites.tsx @@ -7,7 +7,6 @@ import { useMemo } from 'react'; import { STUDIO_DOCS_URL_GET_HELP_UNSUPPORTED_SITES } from '../constants'; import { useSyncSites } from '../hooks/sync-sites'; import { useConfirmationDialog } from '../hooks/use-confirmation-dialog'; -import { SyncSite } from '../hooks/use-fetch-wpcom-sites'; import { useOffline } from '../hooks/use-offline'; import { useSyncStatesProgressInfo } from '../hooks/use-sync-states-progress-info'; import { cx } from '../lib/cx'; @@ -23,6 +22,7 @@ import ProgressBar from './progress-bar'; import { SyncPullPushClear } from './sync-pull-push-clear'; import { Tooltip, DynamicTooltip } from './tooltip'; import { WordPressLogoCircle } from './wordpress-logo-circle'; +import type { SyncSite } from '../hooks/use-fetch-wpcom-sites/types'; interface ConnectedSiteSection { id: number; diff --git a/src/components/sync-sites-modal-selector.tsx b/src/components/sync-sites-modal-selector.tsx index d78caeaac..a2398e184 100644 --- a/src/components/sync-sites-modal-selector.tsx +++ b/src/components/sync-sites-modal-selector.tsx @@ -2,7 +2,6 @@ import { Icon, SearchControl as SearchControlWp } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useState, useEffect } from 'react'; -import { SyncSite } from '../hooks/use-fetch-wpcom-sites'; import { useOffline } from '../hooks/use-offline'; import { cx } from '../lib/cx'; import { getIpcApi } from '../lib/get-ipc-api'; @@ -11,6 +10,7 @@ import Button from './button'; import { CreateButton } from './connect-create-buttons'; import Modal from './modal'; import offlineIcon from './offline-icon'; +import type { SyncSite } from '../hooks/use-fetch-wpcom-sites/types'; const SearchControl = process.env.NODE_ENV === 'test' ? () => null : SearchControlWp; diff --git a/src/hooks/sync-sites/sync-sites-context.tsx b/src/hooks/sync-sites/sync-sites-context.tsx index 2988000ed..eff2030a8 100644 --- a/src/hooks/sync-sites/sync-sites-context.tsx +++ b/src/hooks/sync-sites/sync-sites-context.tsx @@ -1,12 +1,12 @@ import { __, sprintf } from '@wordpress/i18n'; import React, { createContext, useCallback, useContext, useState } from 'react'; import { getIpcApi } from '../../lib/get-ipc-api'; -import { SyncSite } from '../use-fetch-wpcom-sites'; import { useFormatLocalizedTimestamps } from '../use-format-localized-timestamps'; import { useListenDeepLinkConnection } from './use-listen-deep-link-connection'; import { UseSiteSyncManagement, useSiteSyncManagement } from './use-site-sync-management'; import { PullStates, UseSyncPull, useSyncPull } from './use-sync-pull'; import { PushStates, UseSyncPush, useSyncPush } from './use-sync-push'; +import type { SyncSite } from '../use-fetch-wpcom-sites/types'; type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; type UpdateSiteTimestamp = ( diff --git a/src/hooks/sync-sites/use-site-sync-management.ts b/src/hooks/sync-sites/use-site-sync-management.ts index 800a95037..d02e40e73 100644 --- a/src/hooks/sync-sites/use-site-sync-management.ts +++ b/src/hooks/sync-sites/use-site-sync-management.ts @@ -1,103 +1,9 @@ import { useEffect, useCallback } from 'react'; import { getIpcApi } from '../../lib/get-ipc-api'; import { useAuth } from '../use-auth'; -import { FetchSites, SyncSite, useFetchWpComSites } from '../use-fetch-wpcom-sites'; +import { FetchSites, useFetchWpComSites } from '../use-fetch-wpcom-sites'; import { useSiteDetails } from '../use-site-details'; - -/** - * Generate updated site data to be stored in `appdata-v1.json` in three steps: - * 1. Update the list of `connectedSites` with fresh data (name, URL, etc) - * 2. Find any staging sites that have been added to an already connected site - * 3. Find any connected staging sites that have been deleted on WordPress.com - * - * We treat staging sites differently from production sites because users can't connect staging - * sites separately from production sites (they're always connected together). So, while deleted - * production sites are still rendered in the UI (with a "deleted" notice), we need to automatically - * keep the list of staging sites up-to-date, which is where `stagingSitesToAdd` and - * `stagingSitesToDelete` comes in. - */ -export const reconcileConnectedSites = ( - connectedSites: SyncSite[], - freshWpComSites: SyncSite[] -): { - updatedConnectedSites: SyncSite[]; - stagingSitesToAdd: SyncSite[]; - stagingSitesToDelete: { id: number; localSiteId: string }[]; -} => { - const updatedConnectedSites = connectedSites.map( ( connectedSite ): SyncSite => { - const site = freshWpComSites.find( ( site ) => site.id === connectedSite.id ); - - if ( ! site ) { - return { - ...connectedSite, - syncSupport: 'deleted', - }; - } - - return { - ...connectedSite, - name: site.name, - url: site.url, - syncSupport: site.syncSupport, - stagingSiteIds: site.stagingSiteIds, - }; - }, [] ); - - const stagingSitesToAdd = connectedSites.flatMap( ( connectedSite ) => { - const updatedConnectedSite = updatedConnectedSites.find( - ( site ) => site.id === connectedSite.id - ); - - if ( ! updatedConnectedSite?.stagingSiteIds.length ) { - return []; - } - - const addedStagingSiteIds = updatedConnectedSite.stagingSiteIds.filter( - ( id ) => ! connectedSite.stagingSiteIds.includes( id ) - ); - - return addedStagingSiteIds.flatMap( ( id ): SyncSite[] => { - const freshSite = freshWpComSites.find( ( site ) => site.id === id ); - - if ( ! freshSite ) { - return []; - } - - return [ - { - ...freshSite, - localSiteId: connectedSite.localSiteId, - syncSupport: 'already-connected', - }, - ]; - }, [] ); - } ); - - const stagingSitesToDelete = connectedSites.flatMap( ( connectedSite ) => { - const updatedConnectedSite = updatedConnectedSites.find( - ( site ) => site.id === connectedSite.id - ); - - if ( ! connectedSite?.stagingSiteIds.length ) { - return []; - } - - return connectedSite.stagingSiteIds - .filter( ( id ) => ! updatedConnectedSite?.stagingSiteIds.includes( id ) ) - .map( ( id ) => { - return { - id, - localSiteId: connectedSite.localSiteId, - }; - } ); - } ); - - return { - updatedConnectedSites, - stagingSitesToAdd, - stagingSitesToDelete, - }; -}; +import type { SyncSite } from '../use-fetch-wpcom-sites/types'; type ConnectedSites = SyncSite[]; type LoadConnectedSites = () => Promise< void >; @@ -124,7 +30,7 @@ export const useSiteSyncManagement = ( { setConnectedSites, }: UseSiteSyncManagementProps ): UseSiteSyncManagement => { const { isAuthenticated } = useAuth(); - const { syncSites, isFetching, isInitialized, refetchSites } = useFetchWpComSites( + const { syncSites, isFetching, refetchSites } = useFetchWpComSites( connectedSites.map( ( { id } ) => id ) ); const { selectedSite } = useSiteDetails(); @@ -149,50 +55,7 @@ export const useSiteSyncManagement = ( { if ( isAuthenticated ) { loadConnectedSites(); } - }, [ isAuthenticated, loadConnectedSites ] ); - - // whenever array of syncSites changes, we need to update connectedSites to keep them updated with wordpress.com - useEffect( () => { - if ( isFetching || ! isAuthenticated || ! isInitialized ) { - return; - } - - getIpcApi() - .getConnectedWpcomSites() - .then( async ( allConnectedSites ) => { - const { updatedConnectedSites, stagingSitesToAdd, stagingSitesToDelete } = - reconcileConnectedSites( allConnectedSites, syncSites ); - - await getIpcApi().updateConnectedWpcomSites( updatedConnectedSites ); - - if ( stagingSitesToDelete.length ) { - const data = stagingSitesToDelete.map( ( { id, localSiteId } ) => ( { - siteIds: [ id ], - localSiteId, - } ) ); - - await getIpcApi().disconnectWpcomSites( data ); - } - - if ( stagingSitesToAdd.length ) { - const data = stagingSitesToAdd.map( ( site ) => ( { - sites: [ site ], - localSiteId: site.localSiteId, - } ) ); - - await getIpcApi().connectWpcomSites( data ); - } - - loadConnectedSites(); - } ); - }, [ - isAuthenticated, - syncSites, - isFetching, - isInitialized, - setConnectedSites, - loadConnectedSites, - ] ); + }, [ isAuthenticated, syncSites, loadConnectedSites ] ); const connectSite = useCallback< ConnectSite >( async ( site, overrideLocalSiteId ) => { diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index 92cb543d7..41ab71ca4 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -4,7 +4,6 @@ import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useEffect, useMemo } from 'react'; import { getIpcApi } from '../../lib/get-ipc-api'; import { useAuth } from '../use-auth'; -import { SyncSite } from '../use-fetch-wpcom-sites'; import { useImportExport } from '../use-import-export'; import { useSiteDetails } from '../use-site-details'; import { useSyncStatesProgressInfo, PullStateProgressInfo } from '../use-sync-states-progress-info'; @@ -15,6 +14,7 @@ import { UpdateState, usePullPushStates, } from './use-pull-push-states'; +import type { SyncSite } from '../use-fetch-wpcom-sites/types'; export type SyncBackupState = { remoteSiteId: number; diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index db30c385c..d411db2f5 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import { SYNC_PUSH_SIZE_LIMIT_BYTES } from '../../constants'; import { getIpcApi } from '../../lib/get-ipc-api'; import { useAuth } from '../use-auth'; -import { SyncSite } from '../use-fetch-wpcom-sites'; import { useSyncStatesProgressInfo, PushStateProgressInfo } from '../use-sync-states-progress-info'; import { ClearState, @@ -14,6 +13,7 @@ import { UpdateState, usePullPushStates, } from './use-pull-push-states'; +import type { SyncSite } from '../use-fetch-wpcom-sites/types'; export type SyncPushState = { remoteSiteId: number; diff --git a/src/hooks/tests/reconcile-connected-sites.test.ts b/src/hooks/tests/reconcile-connected-sites.test.ts index 407cec036..f9078635a 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 '../sync-sites/use-site-sync-management'; -import { SyncSite } from '../use-fetch-wpcom-sites'; +import { reconcileConnectedSites } from '../use-fetch-wpcom-sites/reconcile-connected-sites'; +import type { SyncSite } from '../use-fetch-wpcom-sites/types'; describe( 'reconcileConnectedSites', () => { test( 'should update name, url, syncSupport properties', () => { diff --git a/src/hooks/use-fetch-wpcom-sites.tsx b/src/hooks/use-fetch-wpcom-sites/index.tsx similarity index 72% rename from src/hooks/use-fetch-wpcom-sites.tsx rename to src/hooks/use-fetch-wpcom-sites/index.tsx index a1fd6b298..e37c1a8aa 100644 --- a/src/hooks/use-fetch-wpcom-sites.tsx +++ b/src/hooks/use-fetch-wpcom-sites/index.tsx @@ -1,27 +1,10 @@ import * as Sentry from '@sentry/electron/renderer'; import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { useAuth } from './use-auth'; -import { useOffline } from './use-offline'; - -type SyncSupport = - | 'unsupported' - | 'syncable' - | 'needs-transfer' - | 'already-connected' - | 'jetpack-site' - | 'deleted'; - -export type SyncSite = { - id: number; - localSiteId: string; - name: string; - url: string; - isStaging: boolean; - stagingSiteIds: number[]; - syncSupport: SyncSupport; - lastPullTimestamp: string | null; - lastPushTimestamp: string | null; -}; +import { getIpcApi } from '../../lib/get-ipc-api'; +import { useAuth } from '../use-auth'; +import { useOffline } from '../use-offline'; +import { reconcileConnectedSites } from './reconcile-connected-sites'; +import type { SyncSite, SyncSupport } from './types'; type SitesEndpointSite = { ID: number; @@ -116,14 +99,13 @@ function transformSiteResponse( export type FetchSites = () => Promise< SitesEndpointSite[] >; -export const useFetchWpComSites = ( connectedSiteIds: number[] ) => { +export const useFetchWpComSites = ( connectedSiteIdsOnlyForSelectedSite: number[] ) => { const [ rawSyncSites, setRawSyncSites ] = useState< SitesEndpointSite[] >( [] ); const { isAuthenticated, client } = useAuth(); const isFetchingSites = useRef( false ); - const isInitialized = useRef( false ); // By default syncSites are always empty array, so this flag helps to determine if we have fetched sites at least once const isOffline = useOffline(); - const joinedConnectedSiteIds = connectedSiteIds.join( ',' ); + 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( @@ -142,6 +124,8 @@ export const useFetchWpComSites = ( connectedSiteIds: number[] ) => { isFetchingSites.current = true; try { + const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); + const response = await client.req.get< SitesEndpointResponse >( { apiNamespace: 'rest/v1.2', @@ -156,7 +140,34 @@ export const useFetchWpComSites = ( connectedSiteIds: number[] ) => { } ); - isInitialized.current = true; + const syncSites = transformSiteResponse( + response.sites, + allConnectedSites.map( ( { id } ) => id ) + ); + + // whenever array of syncSites changes, we need to update connectedSites to keep them updated with wordpress.com + const { updatedConnectedSites, stagingSitesToAdd, stagingSitesToDelete } = + reconcileConnectedSites( allConnectedSites, syncSites ); + + await getIpcApi().updateConnectedWpcomSites( updatedConnectedSites ); + + if ( stagingSitesToDelete.length ) { + const data = stagingSitesToDelete.map( ( { id, localSiteId } ) => ( { + siteIds: [ id ], + localSiteId, + } ) ); + + await getIpcApi().disconnectWpcomSites( data ); + } + + if ( stagingSitesToAdd.length ) { + const data = stagingSitesToAdd.map( ( site ) => ( { + sites: [ site ], + localSiteId: site.localSiteId, + } ) ); + + await getIpcApi().connectWpcomSites( data ); + } setRawSyncSites( response.sites ); @@ -174,19 +185,14 @@ export const useFetchWpComSites = ( connectedSiteIds: number[] ) => { fetchSites(); }, [ fetchSites ] ); - const refetchSites = useCallback( () => { - return fetchSites(); - }, [ fetchSites ] ); - - const syncSites = useMemo( + const syncSitesWithSyncSupportForSelectedSite = useMemo( () => transformSiteResponse( rawSyncSites, memoizedConnectedSiteIds ), [ rawSyncSites, memoizedConnectedSiteIds ] ); return { - syncSites, + syncSites: syncSitesWithSyncSupportForSelectedSite, isFetching: isFetchingSites.current, - isInitialized: isInitialized.current, - refetchSites, + refetchSites: fetchSites, }; }; diff --git a/src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx b/src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx new file mode 100644 index 000000000..292cdb054 --- /dev/null +++ b/src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx @@ -0,0 +1,96 @@ +import { SyncSite } from './types'; + +/** + * Generate updated site data to be stored in `appdata-v1.json` in three steps: + * 1. Update the list of `connectedSites` with fresh data (name, URL, etc) + * 2. Find any staging sites that have been added to an already connected site + * 3. Find any connected staging sites that have been deleted on WordPress.com + * + * We treat staging sites differently from production sites because users can't connect staging + * sites separately from production sites (they're always connected together). So, while deleted + * production sites are still rendered in the UI (with a "deleted" notice), we need to automatically + * keep the list of staging sites up-to-date, which is where `stagingSitesToAdd` and + * `stagingSitesToDelete` comes in. + */ +export const reconcileConnectedSites = ( + connectedSites: SyncSite[], + freshWpComSites: SyncSite[] +): { + updatedConnectedSites: SyncSite[]; + stagingSitesToAdd: SyncSite[]; + stagingSitesToDelete: { id: number; localSiteId: string }[]; +} => { + const updatedConnectedSites = connectedSites.map( ( connectedSite ): SyncSite => { + const site = freshWpComSites.find( ( site ) => site.id === connectedSite.id ); + + if ( ! site ) { + return { + ...connectedSite, + syncSupport: 'deleted', + }; + } + + return { + ...connectedSite, + name: site.name, + url: site.url, + syncSupport: site.syncSupport, + stagingSiteIds: site.stagingSiteIds, + }; + }, [] ); + + const stagingSitesToAdd = connectedSites.flatMap( ( connectedSite ) => { + const updatedConnectedSite = updatedConnectedSites.find( + ( site ) => site.id === connectedSite.id + ); + + if ( ! updatedConnectedSite?.stagingSiteIds.length ) { + return []; + } + + const addedStagingSiteIds = updatedConnectedSite.stagingSiteIds.filter( + ( id ) => ! connectedSite.stagingSiteIds.includes( id ) + ); + + return addedStagingSiteIds.flatMap( ( id ): SyncSite[] => { + const freshSite = freshWpComSites.find( ( site ) => site.id === id ); + + if ( ! freshSite ) { + return []; + } + + return [ + { + ...freshSite, + localSiteId: connectedSite.localSiteId, + syncSupport: 'already-connected', + }, + ]; + }, [] ); + } ); + + const stagingSitesToDelete = connectedSites.flatMap( ( connectedSite ) => { + const updatedConnectedSite = updatedConnectedSites.find( + ( site ) => site.id === connectedSite.id + ); + + if ( ! connectedSite?.stagingSiteIds.length ) { + return []; + } + + return connectedSite.stagingSiteIds + .filter( ( id ) => ! updatedConnectedSite?.stagingSiteIds.includes( id ) ) + .map( ( id ) => { + return { + id, + localSiteId: connectedSite.localSiteId, + }; + } ); + } ); + + return { + updatedConnectedSites, + stagingSitesToAdd, + stagingSitesToDelete, + }; +}; diff --git a/src/hooks/use-fetch-wpcom-sites/types.ts b/src/hooks/use-fetch-wpcom-sites/types.ts new file mode 100644 index 000000000..725c4de38 --- /dev/null +++ b/src/hooks/use-fetch-wpcom-sites/types.ts @@ -0,0 +1,19 @@ +export type SyncSupport = + | 'unsupported' + | 'syncable' + | 'needs-transfer' + | 'already-connected' + | 'jetpack-site' + | 'deleted'; + +export type SyncSite = { + id: number; + localSiteId: string; + name: string; + url: string; + isStaging: boolean; + stagingSiteIds: number[]; + syncSupport: SyncSupport; + lastPullTimestamp: string | null; + lastPushTimestamp: string | null; +}; diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 8e47b47a7..2c169cebc 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -18,7 +18,6 @@ import { __, LocaleData, defaultI18n } from '@wordpress/i18n'; import archiver from 'archiver'; import { DEFAULT_PHP_VERSION } from '../vendor/wp-now/src/constants'; import { MAIN_MIN_WIDTH, SIDEBAR_WIDTH } from './constants'; -import { SyncSite } from './hooks/use-fetch-wpcom-sites'; import { ACTIVE_SYNC_OPERATIONS } from './lib/active-sync-operations'; import { download } from './lib/download'; import { isEmptyDir, pathExists, isWordPressDirectory, sanitizeFolderName } from './lib/fs-utils'; @@ -45,6 +44,7 @@ import { popupMenu, setupMenu } from './menu'; import { SiteServer, createSiteWorkingDirectory } from './site-server'; import { DEFAULT_SITE_PATH, getResourcesPath, getSiteThumbnailPath } from './storage/paths'; import { loadUserData, saveUserData } from './storage/user-data'; +import type { SyncSite } from './hooks/use-fetch-wpcom-sites/types'; import type { WpCliResult } from './lib/wp-cli-process'; const TEMP_DIR = nodePath.join( app.getPath( 'temp' ), 'com.wordpress.studio' ) + nodePath.sep; @@ -245,7 +245,11 @@ export async function connectWpcomSites( event: IpcMainInvokeEvent, list: WpcomS // Add the site if it's not already connected if ( ! isAlreadyConnected ) { - connections.push( { ...siteToAdd, localSiteId } ); + connections.push( { + ...siteToAdd, + localSiteId, + syncSupport: 'already-connected', + } ); } } ); } ); diff --git a/src/preload.ts b/src/preload.ts index 9de8dcae3..667e07863 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,10 +4,10 @@ import '@sentry/electron/preload'; import { SaveDialogOptions, contextBridge, ipcRenderer } from 'electron'; import { LocaleData } from '@wordpress/i18n'; -import { SyncSite } from './hooks/use-fetch-wpcom-sites'; import { ExportOptions } from './lib/import-export/export/types'; import { BackupArchiveInfo } from './lib/import-export/import/types'; import { promptWindowsSpeedUpSites } from './lib/windows-helpers'; +import type { SyncSite } from './hooks/use-fetch-wpcom-sites/types'; import type { LogLevel } from './logging'; const api: IpcApi = { diff --git a/src/storage/storage-types.ts b/src/storage/storage-types.ts index b38f08bdd..48a0c4e22 100644 --- a/src/storage/storage-types.ts +++ b/src/storage/storage-types.ts @@ -1,5 +1,5 @@ -import { SyncSite } from '../hooks/use-fetch-wpcom-sites'; import { StoredToken } from '../lib/oauth'; +import type { SyncSite } from '../hooks/use-fetch-wpcom-sites/types'; export interface UserData { sites: SiteDetails[];