feat: support force sync by click (#4089)

Co-authored-by: JimmFly <yangjinfei001@gmail.com>
This commit is contained in:
Alex Yang
2023-09-01 01:15:07 -05:00
committed by GitHub
parent 83e7e9db8d
commit 92f0b31196
13 changed files with 341 additions and 117 deletions
@@ -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`,
});
@@ -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 (
<svg
className={loading}
viewBox="0 0 1024 1024"
focusable="false"
data-icon="loading"
width={size ? `${size}px` : '.8em'}
height={size ? `${size}px` : '.8em'}
fill="currentColor"
aria-hidden="true"
style={{
...assignInlineVars({
[speedVar]: `${speed}s`,
}),
}}
>
<path d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"></path>
</svg>
);
};
@@ -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 (
<>
<CloudWorkspaceIcon />
AFFiNE Cloud
</>
);
};
const SyncingWorkspaceStatus = () => {
return (
<>
<Loading />
Syncing...
</>
);
};
const UnSyncWorkspaceStatus = () => {
return (
<>
<UnsyncIcon />
Wait for upload
</>
);
};
const LocalWorkspaceStatus = () => {
return (
<>
<LocalWorkspaceIcon />
Local
</>
);
};
const OfflineStatus = () => {
return (
<>
<NoNetworkIcon />
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<HTMLDivElement | null>(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 (
<div style={{ display: 'flex' }}>
<Tooltip
content={content}
portalOptions={{
container,
}}
>
<StyledWorkspaceStatus
onMouseEnter={() => {
setIsHovered(true);
}}
ref={setContainer}
onMouseLeave={() => setIsHovered(false)}
onClick={useCallback(
(e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
startForceSync();
},
[startForceSync]
)}
>
{currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
!isOnline ? (
<OfflineStatus />
) : (
<CloudWorkspaceSyncStatus />
)
) : (
<LocalWorkspaceStatus />
)}
</StyledWorkspaceStatus>
</Tooltip>
</div>
);
};
/**
* @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<HTMLDivElement | null>(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 (
<>
<NoNetworkIcon />
Offline
</>
);
}
return (
<>
{currentWorkspace.flavour === 'local' ? (
<LocalWorkspaceIcon />
) : (
<CloudWorkspaceIcon />
)}
{currentWorkspace.flavour === 'local' ? 'Local' : 'AFFiNE Cloud'}
</>
);
};
return (
<StyledSelectorContainer
role="button"
@@ -106,25 +199,7 @@ export const WorkspaceSelector = ({
<StyledWorkspaceName data-testid="workspace-name">
{name}
</StyledWorkspaceName>
<div style={{ display: 'flex' }}>
<Tooltip
content={content}
portalOptions={{
container,
}}
>
<StyledWorkspaceStatus
onMouseEnter={() => {
setIsHovered(true);
}}
ref={setContainer}
onMouseLeave={() => setIsHovered(false)}
onClick={e => e.stopPropagation()}
>
<WorkspaceStatus />
</StyledWorkspaceStatus>
</Tooltip>
</div>
<WorkspaceStatus currentWorkspace={currentWorkspace} />
</StyledSelectorWrapper>
</StyledSelectorContainer>
);
@@ -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<Status>({
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;
}
+6 -4
View File
@@ -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';
}
+4 -4
View File
@@ -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])
);
}
+8 -1
View File
@@ -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;
@@ -54,11 +54,12 @@ export const createSQLiteProvider: DocProviderCreator = (
id,
rootDoc
): SQLiteProvider => {
let datasource: ReturnType<typeof createDatasource> | null = null;
const datasource = createDatasource(id);
let provider: ReturnType<typeof createLazyProvider> | 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;
},
+22 -10
View File
@@ -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<BlockSuiteBinaryDB>(dbName, dbVersion, {
upgrade: upgradeDB,
});
let dbPromise: Promise<IDBPDatabase<BlockSuiteBinaryDB>> | null = null;
const getDb = async () => {
if (dbPromise === null) {
dbPromise = openDB<BlockSuiteBinaryDB>(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<typeof createIndexedDBDatasource> | null = null;
const datasource = createIndexedDBDatasource({ dbName, mergeCount });
let provider: ReturnType<typeof createLazyProvider> | 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;
+2 -2
View File
@@ -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<BlockSuiteBinaryDB>) {
db.createObjectStore('milestone', { keyPath: 'id' });
}
export interface IndexedDBProvider extends StatusAdapter {
export interface IndexedDBProvider extends DataSourceAdapter {
connect: () => void;
disconnect: () => void;
cleanup: () => Promise<void>;
+2 -2
View File
@@ -1,6 +1,6 @@
import type { DocState, StatusAdapter } from './types';
import type { DocState } from './types';
export interface DatasourceDocAdapter extends Partial<StatusAdapter> {
export interface DatasourceDocAdapter {
/**
* request diff update from other clients
*/
+20 -29
View File
@@ -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<string, Uint8Array[]>(); // guid -> pending-updates
const disposableMap = new Map<string, Set<() => 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,
};
};
+6 -2
View File
@@ -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;
}