From 92f0b3119660b26354a04525bba3ca5c047472d0 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Fri, 1 Sep 2023 01:15:07 -0500 Subject: [PATCH] feat: support force sync by click (#4089) Co-authored-by: JimmFly --- .../WorkspaceSelector/index.css.ts | 18 +- .../WorkspaceSelector/loading-icon.tsx | 30 +++ .../WorkspaceSelector/workspace-selector.tsx | 193 ++++++++++++------ apps/core/src/hooks/use-datasource-sync.ts | 88 ++++++++ packages/env/src/workspace.ts | 10 +- packages/hooks/src/use-data-source-status.ts | 8 +- packages/workspace/src/providers/index.ts | 9 +- .../src/providers/sqlite-providers.ts | 5 +- packages/y-indexeddb/src/provider.ts | 32 ++- packages/y-indexeddb/src/shared.ts | 4 +- packages/y-provider/src/data-source.ts | 4 +- packages/y-provider/src/lazy-provider.ts | 49 ++--- packages/y-provider/src/types.ts | 8 +- 13 files changed, 341 insertions(+), 117 deletions(-) create mode 100644 apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/loading-icon.tsx create mode 100644 apps/core/src/hooks/use-datasource-sync.ts diff --git a/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/index.css.ts b/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/index.css.ts index d9c0432150..eddd87c98c 100644 --- a/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/index.css.ts +++ b/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/index.css.ts @@ -1,4 +1,20 @@ -import { style } from '@vanilla-extract/css'; +import { createVar, keyframes, style } from '@vanilla-extract/css'; export const workspaceAvatarStyle = style({ flexShrink: 0, }); + +export const speedVar = createVar('speedVar'); + +const rotate = keyframes({ + '0%': { transform: 'rotate(0deg)' }, + '50%': { transform: 'rotate(180deg)' }, + '100%': { transform: 'rotate(360deg)' }, +}); +export const loading = style({ + vars: { + [speedVar]: '1.5s', + }, + textRendering: 'optimizeLegibility', + WebkitFontSmoothing: 'antialiased', + animation: `${rotate} ${speedVar} infinite linear`, +}); diff --git a/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/loading-icon.tsx b/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/loading-icon.tsx new file mode 100644 index 0000000000..b939e6521b --- /dev/null +++ b/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/loading-icon.tsx @@ -0,0 +1,30 @@ +import { assignInlineVars } from '@vanilla-extract/dynamic'; + +import { loading, speedVar } from './index.css'; + +export type LoadingProps = { + size?: number; + speed?: number; +}; + +export const Loading = ({ size, speed = 1.2 }: LoadingProps) => { + return ( + + ); +}; diff --git a/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/workspace-selector.tsx b/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/workspace-selector.tsx index 79f3cdd373..ce199973dc 100644 --- a/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/workspace-selector.tsx +++ b/apps/core/src/components/pure/workspace-slider-bar/WorkspaceSelector/workspace-selector.tsx @@ -1,18 +1,27 @@ import { WorkspaceAvatar } from '@affine/component/workspace-avatar'; +import { WorkspaceFlavour } from '@affine/env/workspace'; import { CloudWorkspaceIcon, LocalWorkspaceIcon, NoNetworkIcon, + UnsyncIcon, } from '@blocksuite/icons'; import { Tooltip } from '@toeverything/components/tooltip'; import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; -import type React from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; +import { + type KeyboardEvent, + type MouseEvent, + useCallback, + useMemo, + useState, +} from 'react'; -import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status'; +import { useDatasourceSync } from '../../../../hooks/use-datasource-sync'; import { useSystemOnline } from '../../../../hooks/use-system-online'; import type { AllWorkspace } from '../../../../shared'; import { workspaceAvatarStyle } from './index.css'; +import { Loading } from './loading-icon'; import { StyledSelectorContainer, StyledSelectorWrapper, @@ -25,6 +34,125 @@ export interface WorkspaceSelectorProps { onClick: () => void; } +const hoverAtom = atom(false); + +const CloudWorkspaceStatus = () => { + return ( + <> + + AFFiNE Cloud + + ); +}; +const SyncingWorkspaceStatus = () => { + return ( + <> + + Syncing... + + ); +}; +const UnSyncWorkspaceStatus = () => { + return ( + <> + + Wait for upload + + ); +}; + +const LocalWorkspaceStatus = () => { + return ( + <> + + Local + + ); +}; + +const OfflineStatus = () => { + return ( + <> + + Offline + + ); +}; + +const WorkspaceStatus = ({ + currentWorkspace, +}: { + currentWorkspace: AllWorkspace; +}) => { + const isOnline = useSystemOnline(); + // todo: finish display sync status + const [forceSyncStatus, startForceSync] = useDatasourceSync( + currentWorkspace.blockSuiteWorkspace + ); + const setIsHovered = useSetAtom(hoverAtom); + const [container, setContainer] = useState(null); + const content = useMemo(() => { + if (currentWorkspace.flavour === WorkspaceFlavour.LOCAL) { + return 'Saved locally'; + } + if (!isOnline) { + return 'Disconnected, please check your network connection'; + } + switch (forceSyncStatus.type) { + case 'syncing': + return 'Syncing with AFFiNE Cloud'; + case 'error': + return 'Sync failed due to server issues, please try again later.'; + default: + return 'Sync with AFFiNE Cloud'; + } + }, [currentWorkspace.flavour, forceSyncStatus.type, isOnline]); + const CloudWorkspaceSyncStatus = useCallback(() => { + if (forceSyncStatus.type === 'syncing') { + return SyncingWorkspaceStatus(); + } else if (forceSyncStatus.type === 'error') { + return UnSyncWorkspaceStatus(); + } else { + return CloudWorkspaceStatus(); + } + }, [forceSyncStatus.type]); + return ( +
+ + { + setIsHovered(true); + }} + ref={setContainer} + onMouseLeave={() => setIsHovered(false)} + onClick={useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + startForceSync(); + }, + [startForceSync] + )} + > + {currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? ( + !isOnline ? ( + + ) : ( + + ) + ) : ( + + )} + + +
+ ); +}; + /** * @todo-Doma Co-locate WorkspaceListModal with {@link WorkspaceSelector}, * because it's never used elsewhere. @@ -36,13 +164,11 @@ export const WorkspaceSelector = ({ const [name] = useBlockSuiteWorkspaceName( currentWorkspace.blockSuiteWorkspace ); - const [isHovered, setIsHovered] = useState(false); - const [container, setContainer] = useState(null); // Open dialog when `Enter` or `Space` pressed // TODO-Doma Refactor with `@radix-ui/react-dialog` or other libraries that handle these out of the box and be accessible by default // TODO: Delete this? const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { + (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); // TODO-Doma Rename this callback to `onOpenDialog` or something to reduce ambiguity. @@ -51,41 +177,8 @@ export const WorkspaceSelector = ({ }, [onClick] ); - const loginStatus = useCurrentLoginStatus(); - const isOnline = useSystemOnline(); - const content = useMemo(() => { - if (!isOnline) { - return 'Disconnected, please check your network connection'; - } - if ( - loginStatus === 'authenticated' && - currentWorkspace.flavour !== 'local' - ) { - return 'Sync with AFFiNE Cloud'; - } - return 'Saved locally'; - }, [currentWorkspace.flavour, isOnline, loginStatus]); + const isHovered = useAtomValue(hoverAtom); - const WorkspaceStatus = () => { - if (!isOnline) { - return ( - <> - - Offline - - ); - } - return ( - <> - {currentWorkspace.flavour === 'local' ? ( - - ) : ( - - )} - {currentWorkspace.flavour === 'local' ? 'Local' : 'AFFiNE Cloud'} - - ); - }; return ( {name} -
- - { - setIsHovered(true); - }} - ref={setContainer} - onMouseLeave={() => setIsHovered(false)} - onClick={e => e.stopPropagation()} - > - - - -
+
); diff --git a/apps/core/src/hooks/use-datasource-sync.ts b/apps/core/src/hooks/use-datasource-sync.ts new file mode 100644 index 0000000000..9b4df60a29 --- /dev/null +++ b/apps/core/src/hooks/use-datasource-sync.ts @@ -0,0 +1,88 @@ +import { pushNotificationAtom } from '@affine/component/notification-center'; +import type { + AffineSocketIOProvider, + LocalIndexedDBBackgroundProvider, + SQLiteProvider, +} from '@affine/env/workspace'; +import { type Status, syncDataSource } from '@affine/y-provider'; +import { assertExists } from '@blocksuite/global/utils'; +import type { Workspace } from '@blocksuite/store'; +import { useSetAtom } from 'jotai'; +import { startTransition, useCallback, useMemo, useState } from 'react'; + +export function useDatasourceSync(workspace: Workspace) { + const [status, setStatus] = useState({ + type: 'idle', + }); + const pushNotification = useSetAtom(pushNotificationAtom); + const providers = workspace.providers; + const remoteProvider: AffineSocketIOProvider | undefined = useMemo(() => { + return providers.find( + (provider): provider is AffineSocketIOProvider => + provider.flavour === 'affine-socket-io' + ); + }, [providers]); + const localProvider = useMemo(() => { + const sqliteProvider = providers.find( + (provider): provider is SQLiteProvider => provider.flavour === 'sqlite' + ); + const indexedDbProvider = providers.find( + (provider): provider is LocalIndexedDBBackgroundProvider => + provider.flavour === 'local-indexeddb-background' + ); + const provider = sqliteProvider || indexedDbProvider; + assertExists(provider, 'no local provider'); + return provider; + }, [providers]); + return [ + status, + useCallback(() => { + if (!remoteProvider) { + return; + } + startTransition(() => { + setStatus({ + type: 'syncing', + }); + }); + syncDataSource( + () => [ + workspace.doc.guid, + ...[...workspace.doc.subdocs].map(doc => doc.guid), + ], + remoteProvider.datasource, + localProvider.datasource + ) + .then(() => { + startTransition(() => { + setStatus({ + type: 'synced', + }); + pushNotification({ + title: 'Synced successfully', + type: 'success', + }); + }); + }) + .catch(error => { + startTransition(() => { + setStatus({ + type: 'error', + error, + }); + pushNotification({ + title: 'Unable to Sync', + message: 'Server error, please try again later.', + type: 'error', + }); + }); + }); + }, [ + remoteProvider, + localProvider.datasource, + workspace.doc.guid, + workspace.doc.subdocs, + pushNotification, + ]), + ] as const; +} diff --git a/packages/env/src/workspace.ts b/packages/env/src/workspace.ts index bbe79aa81a..e3f779349a 100644 --- a/packages/env/src/workspace.ts +++ b/packages/env/src/workspace.ts @@ -1,4 +1,4 @@ -import type { StatusAdapter } from '@affine/y-provider'; +import type { DataSourceAdapter } from '@affine/y-provider'; import type { EditorContainer } from '@blocksuite/editor'; import type { Page } from '@blocksuite/store'; import type { @@ -32,7 +32,7 @@ export interface BroadCastChannelProvider extends PassiveDocProvider { * Long polling provider with local IndexedDB */ export interface LocalIndexedDBBackgroundProvider - extends StatusAdapter, + extends DataSourceAdapter, PassiveDocProvider { flavour: 'local-indexeddb-background'; } @@ -41,7 +41,7 @@ export interface LocalIndexedDBDownloadProvider extends ActiveDocProvider { flavour: 'local-indexeddb'; } -export interface SQLiteProvider extends PassiveDocProvider, StatusAdapter { +export interface SQLiteProvider extends PassiveDocProvider, DataSourceAdapter { flavour: 'sqlite'; } @@ -49,7 +49,9 @@ export interface SQLiteDBDownloadProvider extends ActiveDocProvider { flavour: 'sqlite-download'; } -export interface AffineSocketIOProvider extends PassiveDocProvider { +export interface AffineSocketIOProvider + extends PassiveDocProvider, + DataSourceAdapter { flavour: 'affine-socket-io'; } diff --git a/packages/hooks/src/use-data-source-status.ts b/packages/hooks/src/use-data-source-status.ts index 0d80098798..bf0e6c86ba 100644 --- a/packages/hooks/src/use-data-source-status.ts +++ b/packages/hooks/src/use-data-source-status.ts @@ -1,4 +1,4 @@ -import type { Status, StatusAdapter } from '@affine/y-provider'; +import type { DataSourceAdapter, Status } from '@affine/y-provider'; import { useCallback, useSyncExternalStore } from 'react'; type UIStatus = @@ -7,9 +7,9 @@ type UIStatus = type: 'unknown'; }; -export function useDataSourceStatus(datasource: StatusAdapter): UIStatus { +export function useDataSourceStatus(provider: DataSourceAdapter): UIStatus { return useSyncExternalStore( - datasource.subscribeStatusChange, - useCallback(() => datasource.status, [datasource]) + provider.subscribeStatusChange, + useCallback(() => provider.status, [provider]) ); } diff --git a/packages/workspace/src/providers/index.ts b/packages/workspace/src/providers/index.ts index bad631530d..0e425e68be 100644 --- a/packages/workspace/src/providers/index.ts +++ b/packages/workspace/src/providers/index.ts @@ -35,9 +35,15 @@ const createAffineSocketIOProvider: DocProviderCreator = ( { awareness } ): AffineSocketIOProvider => { const dataSource = createAffineDataSource(id, doc, awareness); + const lazyProvider = createLazyProvider(doc, dataSource, { + origin: 'affine-socket-io', + }); return { flavour: 'affine-socket-io', - ...createLazyProvider(doc, dataSource), + ...lazyProvider, + get status() { + return lazyProvider.status; + }, }; }; @@ -50,6 +56,7 @@ const createIndexedDBBackgroundProvider: DocProviderCreator = ( let connected = false; return { flavour: 'local-indexeddb-background', + datasource: indexeddbProvider.datasource, passive: true, get status() { return indexeddbProvider.status; diff --git a/packages/workspace/src/providers/sqlite-providers.ts b/packages/workspace/src/providers/sqlite-providers.ts index 9b4df6caa9..53b48a5c87 100644 --- a/packages/workspace/src/providers/sqlite-providers.ts +++ b/packages/workspace/src/providers/sqlite-providers.ts @@ -54,11 +54,12 @@ export const createSQLiteProvider: DocProviderCreator = ( id, rootDoc ): SQLiteProvider => { - let datasource: ReturnType | null = null; + const datasource = createDatasource(id); let provider: ReturnType | null = null; let connected = false; return { flavour: 'sqlite', + datasource, passive: true, get status() { assertExists(provider); @@ -69,14 +70,12 @@ export const createSQLiteProvider: DocProviderCreator = ( return provider.subscribeStatusChange(onStatusChange); }, connect: () => { - datasource = createDatasource(id); provider = createLazyProvider(rootDoc, datasource, { origin: 'sqlite' }); provider.connect(); connected = true; }, disconnect: () => { provider?.disconnect(); - datasource = null; provider = null; connected = false; }, diff --git a/packages/y-indexeddb/src/provider.ts b/packages/y-indexeddb/src/provider.ts index 1c7fd9e624..5389de2497 100644 --- a/packages/y-indexeddb/src/provider.ts +++ b/packages/y-indexeddb/src/provider.ts @@ -4,6 +4,7 @@ import { writeOperation, } from '@affine/y-provider'; import { assertExists } from '@blocksuite/global/utils'; +import type { IDBPDatabase } from 'idb'; import { openDB } from 'idb'; import type { Doc } from 'yjs'; import { diffUpdate, encodeStateVectorFromUpdate } from 'yjs'; @@ -31,13 +32,20 @@ export const createIndexedDBDatasource = ({ dbName: string; mergeCount?: number; }) => { - const dbPromise = openDB(dbName, dbVersion, { - upgrade: upgradeDB, - }); + let dbPromise: Promise> | null = null; + const getDb = async () => { + if (dbPromise === null) { + dbPromise = openDB(dbName, dbVersion, { + upgrade: upgradeDB, + }); + } + return dbPromise; + }; + const adapter = { queryDocState: async (guid, options) => { try { - const db = await dbPromise; + const db = await getDb(); const store = db .transaction('workspace', 'readonly') .objectStore('workspace'); @@ -64,7 +72,7 @@ export const createIndexedDBDatasource = ({ }, sendDocUpdate: async (guid, update) => { try { - const db = await dbPromise; + const db = await getDb(); const store = db .transaction('workspace', 'readwrite') .objectStore('workspace'); @@ -96,10 +104,15 @@ export const createIndexedDBDatasource = ({ return { ...adapter, disconnect: () => { - dbPromise.then(db => db.close()).catch(console.error); + getDb() + .then(db => db.close()) + .then(() => { + dbPromise = null; + }) + .catch(console.error); }, cleanup: async () => { - const db = await dbPromise; + const db = await getDb(); await db.clear('workspace'); }, }; @@ -112,7 +125,7 @@ export const createIndexedDBProvider = ( doc: Doc, dbName: string = DEFAULT_DB_NAME ): IndexedDBProvider => { - let datasource: ReturnType | null = null; + const datasource = createIndexedDBDatasource({ dbName, mergeCount }); let provider: ReturnType | null = null; const apis = { @@ -128,14 +141,12 @@ export const createIndexedDBProvider = ( if (apis.connected) { apis.disconnect(); } - datasource = createIndexedDBDatasource({ dbName, mergeCount }); provider = createLazyProvider(doc, datasource, { origin: 'idb' }); provider.connect(); }, disconnect: () => { datasource?.disconnect(); provider?.disconnect(); - datasource = null; provider = null; }, cleanup: async () => { @@ -144,6 +155,7 @@ export const createIndexedDBProvider = ( get connected() { return provider?.connected || false; }, + datasource, } satisfies IndexedDBProvider; return apis; diff --git a/packages/y-indexeddb/src/shared.ts b/packages/y-indexeddb/src/shared.ts index 3e874e05c4..ed85604fa6 100644 --- a/packages/y-indexeddb/src/shared.ts +++ b/packages/y-indexeddb/src/shared.ts @@ -1,4 +1,4 @@ -import type { StatusAdapter } from '@affine/y-provider'; +import type { DataSourceAdapter } from '@affine/y-provider'; import type { DBSchema, IDBPDatabase } from 'idb'; export const dbVersion = 1; @@ -9,7 +9,7 @@ export function upgradeDB(db: IDBPDatabase) { db.createObjectStore('milestone', { keyPath: 'id' }); } -export interface IndexedDBProvider extends StatusAdapter { +export interface IndexedDBProvider extends DataSourceAdapter { connect: () => void; disconnect: () => void; cleanup: () => Promise; diff --git a/packages/y-provider/src/data-source.ts b/packages/y-provider/src/data-source.ts index 94874ffb1f..f5cbecacda 100644 --- a/packages/y-provider/src/data-source.ts +++ b/packages/y-provider/src/data-source.ts @@ -1,6 +1,6 @@ -import type { DocState, StatusAdapter } from './types'; +import type { DocState } from './types'; -export interface DatasourceDocAdapter extends Partial { +export interface DatasourceDocAdapter { /** * request diff update from other clients */ diff --git a/packages/y-provider/src/lazy-provider.ts b/packages/y-provider/src/lazy-provider.ts index 115626e9f4..65db5b0b57 100644 --- a/packages/y-provider/src/lazy-provider.ts +++ b/packages/y-provider/src/lazy-provider.ts @@ -7,7 +7,7 @@ import { } from 'yjs'; import type { DatasourceDocAdapter } from './data-source'; -import type { StatusAdapter } from './types'; +import type { DataSourceAdapter } from './types'; import type { Status } from './types'; function getDoc(doc: Doc, guid: string): Doc | undefined { @@ -45,7 +45,7 @@ export const createLazyProvider = ( rootDoc: Doc, datasource: DatasourceDocAdapter, options: LazyProviderOptions = {} -): DocProvider & StatusAdapter => { +): DocProvider & DataSourceAdapter => { let connected = false; const pendingMap = new Map(); // guid -> pending-updates const disposableMap = new Map void>>(); @@ -62,21 +62,17 @@ export const createLazyProvider = ( const callbackSet = new Set<() => void>(); const changeStatus = (newStatus: Status) => { // simulate a stack, each syncing and synced should be paired - if (newStatus.type === 'idle') { - if (connected && syncingStack !== 0) { - console.error('syncingStatus !== 0, this should not happen'); - } - syncingStack = 0; - } if (newStatus.type === 'syncing') { syncingStack++; - } - if (newStatus.type === 'synced' || newStatus.type === 'error') { + } else if (newStatus.type === 'synced' || newStatus.type === 'error') { syncingStack--; } if (syncingStack < 0) { - console.error('syncingStatus < 0, this should not happen'); + console.error( + 'syncingStatus < 0, this should not happen', + options.origin + ); } if (syncingStack === 0) { @@ -85,6 +81,17 @@ export const createLazyProvider = ( if (newStatus.type !== 'synced') { currentStatus = newStatus; } + if (syncingStack === 0) { + if (!connected) { + currentStatus = { + type: 'idle', + }; + } else { + currentStatus = { + type: 'synced', + }; + } + } callbackSet.forEach(cb => cb()); }; @@ -102,12 +109,6 @@ export const createLazyProvider = ( stateVector: encodeStateVector(doc), }) .then(remoteUpdate => { - if (!connected) { - changeStatus({ - type: 'idle', - }); - return; - } changeStatus({ type: 'synced', }); @@ -201,9 +202,6 @@ export const createLazyProvider = ( function setupDatasourceListeners() { assertExists(abortController, 'abortController should be defined'); const unsubscribe = datasource.onDocUpdate?.((guid, update) => { - if (!connected) { - return; - } changeStatus({ type: 'syncing', }); @@ -283,12 +281,6 @@ export const createLazyProvider = ( // but we want to populate the cache for later update events connectDoc(rootDoc) .then(() => { - if (!connected) { - changeStatus({ - type: 'idle', - }); - return; - } changeStatus({ type: 'synced', }); @@ -305,9 +297,6 @@ export const createLazyProvider = ( async function disconnect() { connected = false; - changeStatus({ - type: 'idle', - }); disposeAll(); assertExists(abortController, 'abortController should be defined'); abortController.abort(); @@ -349,5 +338,7 @@ export const createLazyProvider = ( passive: true, connect, disconnect, + + datasource, }; }; diff --git a/packages/y-provider/src/types.ts b/packages/y-provider/src/types.ts index bf44210900..6ad651abfb 100644 --- a/packages/y-provider/src/types.ts +++ b/packages/y-provider/src/types.ts @@ -1,3 +1,5 @@ +import type { DatasourceDocAdapter } from './data-source'; + export type Status = | { type: 'idle'; @@ -10,11 +12,13 @@ export type Status = } | { type: 'error'; - error: Error; + error: unknown; }; -export interface StatusAdapter { +export interface DataSourceAdapter { + datasource: DatasourceDocAdapter; readonly status: Status; + subscribeStatusChange(onStatusChange: () => void): () => void; }