feat(core): show sync state at doc info (#7244)
This commit is contained in:
@@ -345,7 +345,7 @@ export class LiveData<T = unknown>
|
|||||||
duration: number,
|
duration: number,
|
||||||
{ trailing = true, leading = true }: ThrottleConfig = {}
|
{ trailing = true, leading = true }: ThrottleConfig = {}
|
||||||
) {
|
) {
|
||||||
return LiveData.from(
|
return LiveData.from<T>(
|
||||||
this.pipe(throttleTime(duration, undefined, { trailing, leading })),
|
this.pipe(throttleTime(duration, undefined, { trailing, leading })),
|
||||||
null as any
|
null as any
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,12 +53,25 @@ export class DocEngine {
|
|||||||
const localState$ = this.localPart.docState$(docId);
|
const localState$ = this.localPart.docState$(docId);
|
||||||
const remoteState$ = this.remotePart?.docState$(docId);
|
const remoteState$ = this.remotePart?.docState$(docId);
|
||||||
return LiveData.computed(get => {
|
return LiveData.computed(get => {
|
||||||
const local = get(localState$);
|
const localState = get(localState$);
|
||||||
const remote = remoteState$ ? get(remoteState$) : null;
|
const remoteState = remoteState$ ? get(remoteState$) : null;
|
||||||
|
if (remoteState) {
|
||||||
|
return {
|
||||||
|
syncing: remoteState.syncing,
|
||||||
|
saving: localState.syncing,
|
||||||
|
retrying: remoteState.retrying,
|
||||||
|
ready: localState.ready,
|
||||||
|
errorMessage: remoteState.errorMessage,
|
||||||
|
serverClock: remoteState.serverClock,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ready: local.ready,
|
syncing: localState.syncing,
|
||||||
saving: local.syncing,
|
saving: localState.syncing,
|
||||||
syncing: local.syncing || remote?.syncing,
|
ready: localState.ready,
|
||||||
|
retrying: false,
|
||||||
|
errorMessage: null,
|
||||||
|
serverClock: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export interface RemoteEngineState {
|
|||||||
|
|
||||||
export interface RemoteDocState {
|
export interface RemoteDocState {
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
|
retrying: boolean;
|
||||||
|
serverClock: number | null;
|
||||||
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DocEngineRemotePart {
|
export class DocEngineRemotePart {
|
||||||
@@ -87,20 +90,22 @@ export class DocEngineRemotePart {
|
|||||||
new Observable(subscribe => {
|
new Observable(subscribe => {
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (!this.status.syncing) {
|
if (!this.status.syncing) {
|
||||||
|
// if syncing = false, jobMap is empty
|
||||||
subscribe.next({
|
subscribe.next({
|
||||||
total: this.status.docs.size,
|
total: this.status.docs.size,
|
||||||
syncing: this.status.docs.size,
|
syncing: this.status.docs.size,
|
||||||
retrying: this.status.retrying,
|
retrying: this.status.retrying,
|
||||||
errorMessage: this.status.errorMessage,
|
errorMessage: this.status.errorMessage,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const syncing = this.status.jobMap.size;
|
||||||
|
subscribe.next({
|
||||||
|
total: this.status.docs.size,
|
||||||
|
syncing: syncing,
|
||||||
|
retrying: this.status.retrying,
|
||||||
|
errorMessage: this.status.errorMessage,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const syncing = this.status.jobMap.size;
|
|
||||||
subscribe.next({
|
|
||||||
total: this.status.docs.size,
|
|
||||||
syncing: syncing,
|
|
||||||
retrying: this.status.retrying,
|
|
||||||
errorMessage: this.status.errorMessage,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
return this.statusUpdatedSubject$.subscribe(() => {
|
return this.statusUpdatedSubject$.subscribe(() => {
|
||||||
@@ -123,6 +128,9 @@ export class DocEngineRemotePart {
|
|||||||
syncing:
|
syncing:
|
||||||
!this.status.connectedDocs.has(docId) ||
|
!this.status.connectedDocs.has(docId) ||
|
||||||
this.status.jobMap.has(docId),
|
this.status.jobMap.has(docId),
|
||||||
|
serverClock: this.status.serverClocks.get(docId),
|
||||||
|
retrying: this.status.retrying,
|
||||||
|
errorMessage: this.status.errorMessage,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
@@ -130,7 +138,7 @@ export class DocEngineRemotePart {
|
|||||||
if (updatedId === true || updatedId === docId) next();
|
if (updatedId === true || updatedId === docId) next();
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
{ syncing: false }
|
{ syncing: false, retrying: false, errorMessage: null, serverClock: null }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +334,7 @@ export class DocEngineRemotePart {
|
|||||||
readonly actions = {
|
readonly actions = {
|
||||||
updateServerClock: (docId: string, serverClock: number) => {
|
updateServerClock: (docId: string, serverClock: number) => {
|
||||||
this.status.serverClocks.setIfBigger(docId, serverClock);
|
this.status.serverClocks.setIfBigger(docId, serverClock);
|
||||||
|
this.statusUpdatedSubject$.next(docId);
|
||||||
},
|
},
|
||||||
addDoc: (docId: string) => {
|
addDoc: (docId: string) => {
|
||||||
if (!this.status.docs.has(docId)) {
|
if (!this.status.docs.has(docId)) {
|
||||||
@@ -359,7 +368,6 @@ export class DocEngineRemotePart {
|
|||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
this.status.retrying = false;
|
|
||||||
await this.retryLoop(signal);
|
await this.retryLoop(signal);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
@@ -448,6 +456,10 @@ export class DocEngineRemotePart {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// reset retrying flag after connected with server
|
||||||
|
this.status.retrying = false;
|
||||||
|
this.statusUpdatedSubject$.next(true);
|
||||||
|
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
disposes.push(
|
disposes.push(
|
||||||
await this.server.subscribeAllDocs(({ docId, data, serverClock }) => {
|
await this.server.subscribeAllDocs(({ docId, data, serverClock }) => {
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ export const backlinksList = style({
|
|||||||
|
|
||||||
export const tableHeaderTimestamp = style({
|
export const tableHeaderTimestamp = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'start',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
padding: '0 6px',
|
padding: '0 6px',
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import type {
|
|||||||
PageInfoCustomPropertyMeta,
|
PageInfoCustomPropertyMeta,
|
||||||
PagePropertyType,
|
PagePropertyType,
|
||||||
} from '@affine/core/modules/properties/services/schema';
|
} from '@affine/core/modules/properties/services/schema';
|
||||||
import { timestampToLocalDate } from '@affine/core/utils';
|
import {
|
||||||
|
timestampToHumanTime,
|
||||||
|
timestampToLocalDate,
|
||||||
|
timestampToLocalDateTime,
|
||||||
|
} from '@affine/core/utils';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +45,12 @@ import {
|
|||||||
} from '@dnd-kit/modifiers';
|
} from '@dnd-kit/modifiers';
|
||||||
import { SortableContext, useSortable } from '@dnd-kit/sortable';
|
import { SortableContext, useSortable } from '@dnd-kit/sortable';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
|
import {
|
||||||
|
DocService,
|
||||||
|
useLiveData,
|
||||||
|
useServices,
|
||||||
|
WorkspaceService,
|
||||||
|
} from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { use } from 'foxact/use';
|
import { use } from 'foxact/use';
|
||||||
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
@@ -596,35 +606,69 @@ export const PagePropertiesTableHeader = ({
|
|||||||
manager.pageId
|
manager.pageId
|
||||||
);
|
);
|
||||||
|
|
||||||
const timestampElement = useMemo(() => {
|
const { docService, workspaceService } = useServices({
|
||||||
const localizedUpdateTime = manager.updatedDate
|
DocService,
|
||||||
? timestampToLocalDate(manager.updatedDate)
|
WorkspaceService,
|
||||||
: null;
|
});
|
||||||
|
|
||||||
|
const { syncing, retrying, serverClock } = useLiveData(
|
||||||
|
workspaceService.workspace.engine.doc.docState$(docService.doc.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const timestampElement = useMemo(() => {
|
||||||
const localizedCreateTime = manager.createDate
|
const localizedCreateTime = manager.createDate
|
||||||
? timestampToLocalDate(manager.createDate)
|
? timestampToLocalDate(manager.createDate)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const updateTimeElement = (
|
|
||||||
<div className={styles.tableHeaderTimestamp}>
|
|
||||||
{t['Updated']()} {localizedUpdateTime}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const createTimeElement = (
|
const createTimeElement = (
|
||||||
<div className={styles.tableHeaderTimestamp}>
|
<div className={styles.tableHeaderTimestamp}>
|
||||||
{t['Created']()} {localizedCreateTime}
|
{t['Created']()} {localizedCreateTime}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return localizedUpdateTime ? (
|
return serverClock ? (
|
||||||
|
<Tooltip
|
||||||
|
side="right"
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
<div className={styles.tableHeaderTimestamp}>
|
||||||
|
{t['Updated']()} {timestampToLocalDateTime(serverClock)}
|
||||||
|
</div>
|
||||||
|
{manager.createDate && (
|
||||||
|
<div className={styles.tableHeaderTimestamp}>
|
||||||
|
{t['Created']()} {timestampToLocalDateTime(manager.createDate)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.tableHeaderTimestamp}>
|
||||||
|
{!syncing && !retrying ? (
|
||||||
|
<>
|
||||||
|
{t['Updated']()} {timestampToHumanTime(serverClock)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>{t['com.affine.syncing']()}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : manager.updatedDate ? (
|
||||||
<Tooltip side="right" content={createTimeElement}>
|
<Tooltip side="right" content={createTimeElement}>
|
||||||
{updateTimeElement}
|
<div className={styles.tableHeaderTimestamp}>
|
||||||
|
{t['Updated']()} {timestampToLocalDate(manager.updatedDate)}
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
createTimeElement
|
createTimeElement
|
||||||
);
|
);
|
||||||
}, [manager.createDate, manager.updatedDate, t]);
|
}, [
|
||||||
|
manager.createDate,
|
||||||
|
manager.updatedDate,
|
||||||
|
retrying,
|
||||||
|
serverClock,
|
||||||
|
syncing,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleCollapse = useCallback(() => {
|
const handleCollapse = useCallback(() => {
|
||||||
onOpenChange(!open);
|
onOpenChange(!open);
|
||||||
|
|||||||
@@ -145,20 +145,22 @@ const useSyncEngineSyncProgress = () => {
|
|||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
return 'Disconnected, please check your network connection';
|
return 'Disconnected, please check your network connection';
|
||||||
}
|
}
|
||||||
if (syncing) {
|
if (isOverCapacity) {
|
||||||
return (
|
return 'Sync failed due to insufficient cloud storage space.';
|
||||||
`Syncing with AFFiNE Cloud` +
|
}
|
||||||
(progress ? ` (${Math.floor(progress * 100)}%)` : '')
|
if (retrying && errorMessage) {
|
||||||
);
|
|
||||||
} else if (retrying && errorMessage) {
|
|
||||||
return `${errorMessage}, reconnecting.`;
|
return `${errorMessage}, reconnecting.`;
|
||||||
}
|
}
|
||||||
if (retrying) {
|
if (retrying) {
|
||||||
return 'Sync disconnected due to unexpected issues, reconnecting.';
|
return 'Sync disconnected due to unexpected issues, reconnecting.';
|
||||||
}
|
}
|
||||||
if (isOverCapacity) {
|
if (syncing) {
|
||||||
return 'Sync failed due to insufficient cloud storage space.';
|
return (
|
||||||
|
`Syncing with AFFiNE Cloud` +
|
||||||
|
(progress ? ` (${Math.floor(progress * 100)}%)` : '')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Synced with AFFiNE Cloud';
|
return 'Synced with AFFiNE Cloud';
|
||||||
}, [
|
}, [
|
||||||
currentWorkspace.flavour,
|
currentWorkspace.flavour,
|
||||||
@@ -196,7 +198,8 @@ const useSyncEngineSyncProgress = () => {
|
|||||||
),
|
),
|
||||||
active:
|
active:
|
||||||
currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD &&
|
currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD &&
|
||||||
((syncing && progress !== undefined) || isOverCapacity || !isOnline),
|
((syncing && progress !== undefined) || retrying) && // active if syncing or retrying
|
||||||
|
!isOverCapacity, // not active if isOffline or OverCapacity
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const usePauseAnimation = (timeToResume = 5000) => {
|
const usePauseAnimation = (timeToResume = 5000) => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function useDocEngineStatus() {
|
|||||||
() => ({
|
() => ({
|
||||||
...engineState,
|
...engineState,
|
||||||
progress,
|
progress,
|
||||||
syncing: engineState.syncing > 0,
|
syncing: engineState.syncing > 0 || engineState.retrying,
|
||||||
}),
|
}),
|
||||||
[engineState, progress]
|
[engineState, progress]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ function createTimeFormatter() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDateTimeFormatter() {
|
||||||
|
return new Intl.DateTimeFormat(getI18n()?.language, {
|
||||||
|
timeStyle: 'medium',
|
||||||
|
dateStyle: 'medium',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createDateFormatter() {
|
function createDateFormatter() {
|
||||||
return new Intl.DateTimeFormat(getI18n()?.language, {
|
return new Intl.DateTimeFormat(getI18n()?.language, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -31,6 +38,17 @@ export const timestampToLocalDate = (ts: string | number) => {
|
|||||||
return formatter.format(dayjs(ts).toDate());
|
return formatter.format(dayjs(ts).toDate());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const timestampToLocalDateTime = (ts: string | number) => {
|
||||||
|
const formatter = createDateTimeFormatter();
|
||||||
|
return formatter.format(dayjs(ts).toDate());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRelativeTimeFormatter = () => {
|
||||||
|
return new Intl.RelativeTimeFormat(getI18n()?.language, {
|
||||||
|
style: 'narrow',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export interface CalendarTranslation {
|
export interface CalendarTranslation {
|
||||||
yesterday: () => string;
|
yesterday: () => string;
|
||||||
today: () => string;
|
today: () => string;
|
||||||
@@ -64,3 +82,24 @@ export const timestampToCalendarDate = (
|
|||||||
? `${translation.nextWeek()} ${week}`
|
? `${translation.nextWeek()} ${week}`
|
||||||
: sameElse;
|
: sameElse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: refactor this to @affine/i18n
|
||||||
|
export const timestampToHumanTime = (ts: number) => {
|
||||||
|
const diff = Math.abs(dayjs(ts).diff(dayjs()));
|
||||||
|
|
||||||
|
if (diff < 1000 * 60) {
|
||||||
|
return getI18n().t('com.affine.just-now');
|
||||||
|
} else if (diff < 1000 * 60 * 60) {
|
||||||
|
return createRelativeTimeFormatter().format(
|
||||||
|
-Math.floor(diff / 1000 / 60),
|
||||||
|
'minutes'
|
||||||
|
);
|
||||||
|
} else if (diff < 1000 * 60 * 60 * 24) {
|
||||||
|
return createRelativeTimeFormatter().format(
|
||||||
|
-Math.floor(diff / 1000 / 60 / 60),
|
||||||
|
'hours'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return timestampToLocalDate(ts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1303,6 +1303,8 @@
|
|||||||
"com.affine.workspaceType.offline": "Available Offline",
|
"com.affine.workspaceType.offline": "Available Offline",
|
||||||
"com.affine.write_with_a_blank_page": "Write with a blank page",
|
"com.affine.write_with_a_blank_page": "Write with a blank page",
|
||||||
"com.affine.yesterday": "Yesterday",
|
"com.affine.yesterday": "Yesterday",
|
||||||
|
"com.affine.just-now": "Just now",
|
||||||
|
"com.affine.syncing": "Syncing",
|
||||||
"core": "core",
|
"core": "core",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"emptyAllPages": "Click on the <1>$t(New Doc)</1> button to create your first doc.",
|
"emptyAllPages": "Click on the <1>$t(New Doc)</1> button to create your first doc.",
|
||||||
|
|||||||
Reference in New Issue
Block a user