feat: page view storage with cloud support (#4238)

This commit is contained in:
Alex Yang
2023-09-08 15:02:22 -07:00
committed by GitHub
parent 58a935b31d
commit 5f0605a5d9
16 changed files with 338 additions and 94 deletions
+4
View File
@@ -0,0 +1,4 @@
import { atom } from 'jotai';
import type { SessionContextValue } from 'next-auth/react';
export const sessionAtom = atom<SessionContextValue<true> | null>(null);
@@ -19,6 +19,7 @@ import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
import type { BlockSuiteWorkspace } from '../../../shared';
import { toast } from '../../../utils';
import { filterPage } from '../../../utils/filter';
import { currentCollectionsAtom } from '../../../utils/user-setting';
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
import { usePageHelper } from './utils';
@@ -277,7 +278,7 @@ export const BlockSuitePageList = ({
return (
<PageList
workspaceId={blockSuiteWorkspace.id}
collectionsAtom={currentCollectionsAtom}
propertiesMeta={blockSuiteWorkspace.meta.properties}
getPageInfo={getPageInfo}
onCreateNewPage={createPage}
@@ -11,6 +11,7 @@ import { IconButton } from '@toeverything/components/button';
import { useCallback, useState } from 'react';
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
import { currentCollectionsAtom } from '../../../../utils/user-setting';
type AddCollectionButtonProps = {
workspace: Workspace;
@@ -20,7 +21,7 @@ export const AddCollectionButton = ({
workspace,
}: AddCollectionButtonProps) => {
const getPageInfo = useGetPageInfoById(workspace);
const setting = useCollectionManager(workspace.id);
const setting = useCollectionManager(currentCollectionsAtom);
const t = useAFFiNEI18N();
const [show, showUpdateCollection] = useState(false);
const [defaultCollection, setDefaultCollection] = useState<Collection>();
@@ -29,11 +29,12 @@ import {
} from '@toeverything/components/menu';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import type { ReactElement } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
import { filterPage } from '../../../../utils/filter';
import { currentCollectionsAtom } from '../../../../utils/user-setting';
import type { CollectionsListProps } from '../index';
import { Page } from './page';
import * as styles from './styles.css';
@@ -148,8 +149,8 @@ const CollectionRenderer = ({
workspace: Workspace;
getPageInfo: GetPageInfoById;
}) => {
const [collapsed, setCollapsed] = React.useState(true);
const setting = useCollectionManager(workspace.id);
const [collapsed, setCollapsed] = useState(true);
const setting = useCollectionManager(currentCollectionsAtom);
const { jumpToSubPath } = useNavigateHelper();
const clickCollection = useCallback(() => {
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
@@ -272,7 +273,7 @@ const CollectionRenderer = ({
};
export const CollectionsList = ({ workspace }: CollectionsListProps) => {
const metas = useBlockSuitePageMeta(workspace);
const { savedCollections } = useSavedCollections(workspace.id);
const { savedCollections } = useSavedCollections(currentCollectionsAtom);
const getPageInfo = useGetPageInfoById(workspace);
const pinedCollections = useMemo(
() => savedCollections.filter(v => v.pinned),
@@ -28,6 +28,7 @@ import React, { useCallback, useEffect, useMemo } from 'react';
import { useHistoryAtom } from '../../atoms/history';
import { useAppSetting } from '../../atoms/settings';
import type { AllWorkspace } from '../../shared';
import { currentCollectionsAtom } from '../../utils/user-setting';
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-favourite-button';
@@ -98,7 +99,7 @@ export const RootAppSidebar = ({
}: RootAppSidebarProps): ReactElement => {
const currentWorkspaceId = currentWorkspace.id;
const [appSettings] = useAppSetting();
const { backToAll } = useCollectionManager(currentWorkspace.id);
const { backToAll } = useCollectionManager(currentCollectionsAtom);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const t = useAFFiNEI18N();
const onClickNewPage = useCallback(async () => {
@@ -18,6 +18,7 @@ import { useCallback } from 'react';
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
import { useGetPageInfoById } from '../hooks/use-get-page-info';
import { useWorkspace } from '../hooks/use-workspace';
import { currentCollectionsAtom } from '../utils/user-setting';
import { SharePageModal } from './affine/share-page-modal';
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
import { filterContainerStyle } from './filter-container.css';
@@ -27,7 +28,7 @@ import { WorkspaceModeFilterTab } from './pure/workspace-mode-filter-tab';
const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
const currentWorkspace = useWorkspace(workspaceId);
const setting = useCollectionManager(workspaceId);
const setting = useCollectionManager(currentCollectionsAtom);
const saveToCollection = useCallback(
async (collection: Collection) => {
await setting.saveCollection(collection);
@@ -78,10 +79,10 @@ export function WorkspaceHeader({
currentWorkspaceId,
currentEntry,
}: WorkspaceHeaderProps<WorkspaceFlavour>) {
const setting = useCollectionManager(currentWorkspaceId);
const setAppHeader = useSetAtom(appHeaderAtom);
const currentWorkspace = useWorkspace(currentWorkspaceId);
const setting = useCollectionManager(currentCollectionsAtom);
const getPageInfoById = useGetPageInfoById(
currentWorkspace.blockSuiteWorkspace
);
+2 -1
View File
@@ -11,6 +11,7 @@ import { redirect } from 'react-router-dom';
import { getUIAdapter } from '../../adapters/workspace';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { currentCollectionsAtom } from '../../utils/user-setting';
export const loader: LoaderFunction = async args => {
const rootStore = getCurrentStore();
@@ -35,7 +36,7 @@ export const loader: LoaderFunction = async args => {
export const AllPage = () => {
const { jumpToPage } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace();
const setting = useCollectionManager(currentWorkspace.id);
const setting = useCollectionManager(currentCollectionsAtom);
const onClickPage = useCallback(
(pageId: string, newTab?: boolean) => {
assertExists(currentWorkspace);
@@ -26,6 +26,7 @@ import { setPageModeAtom } from '../../atoms';
import { currentModeAtom } from '../../atoms/mode';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { currentCollectionsAtom } from '../../utils/user-setting';
const DetailPageImpl = (): ReactElement => {
const { openPage, jumpToSubPath } = useNavigateHelper();
@@ -34,7 +35,7 @@ const DetailPageImpl = (): ReactElement => {
assertExists(currentWorkspace);
assertExists(currentPageId);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const collectionManager = useCollectionManager(currentWorkspace.id);
const collectionManager = useCollectionManager(currentCollectionsAtom);
const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom);
+10 -5
View File
@@ -4,21 +4,27 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
import { isDesktop } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
import { useSetAtom } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { SessionProvider, useSession } from 'next-auth/react';
import { type PropsWithChildren, startTransition, useRef } from 'react';
import { sessionAtom } from '../atoms/cloud-user';
import { useOnceSignedInEvents } from '../atoms/event';
const SessionReporter = () => {
const SessionDefence = (props: PropsWithChildren) => {
const session = useSession();
const prevSession = useRef<ReturnType<typeof useSession>>();
const [sessionInAtom, setSession] = useAtom(sessionAtom);
const pushNotification = useSetAtom(pushNotificationAtom);
const refreshMetadata = useSetAtom(refreshRootMetadataAtom);
const onceSignedInEvents = useOnceSignedInEvents();
const t = useAFFiNEI18N();
if (sessionInAtom !== session && session.status === 'authenticated') {
setSession(session);
}
if (prevSession.current !== session && session.status !== 'loading') {
// unauthenticated -> authenticated
if (
@@ -42,14 +48,13 @@ const SessionReporter = () => {
}
prevSession.current = session;
}
return null;
return props.children;
};
export const CloudSessionProvider = ({ children }: PropsWithChildren) => {
return (
<SessionProvider refetchOnWindowFocus>
<SessionReporter />
{children}
<SessionDefence>{children}</SessionDefence>
</SessionProvider>
);
};
+206
View File
@@ -0,0 +1,206 @@
import type { CollectionsAtom } from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { DisposableGroup } from '@blocksuite/global/utils';
import { currentWorkspaceAtom } from '@toeverything/infra/atom';
import { type DBSchema, openDB } from 'idb';
import { atom } from 'jotai';
import { atomWithObservable } from 'jotai/utils';
import { Observable } from 'rxjs';
import type { Map as YMap } from 'yjs';
import { Doc as YDoc } from 'yjs';
import { sessionAtom } from '../atoms/cloud-user';
export interface PageCollectionDBV1 extends DBSchema {
view: {
key: Collection['id'];
value: Collection;
};
}
export interface StorageCRUD<Value> {
get: (key: string) => Promise<Value | null>;
set: (key: string, value: Value) => Promise<string>;
delete: (key: string) => Promise<void>;
list: () => Promise<string[]>;
}
type Subscribe = () => void;
const collectionDBAtom = atom(
openDB<PageCollectionDBV1>('page-view', 1, {
upgrade(database) {
database.createObjectStore('view', {
keyPath: 'id',
});
},
})
);
const callbackSet = new Set<Subscribe>();
const localCollectionCRUDAtom = atom(get => ({
get: async (key: string) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view').objectStore('view');
return (await t.get(key)) ?? null;
},
set: async (key: string, value: Collection) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view', 'readwrite').objectStore('view');
await t.put(value);
callbackSet.forEach(cb => cb());
return key;
},
delete: async (key: string) => {
const db = await get(collectionDBAtom);
const t = db.transaction('view', 'readwrite').objectStore('view');
callbackSet.forEach(cb => cb());
await t.delete(key);
},
list: async () => {
const db = await get(collectionDBAtom);
const t = db.transaction('view').objectStore('view');
return t.getAllKeys();
},
}));
const getCollections = async (
storage: StorageCRUD<Collection>
): Promise<Collection[]> => {
return storage
.list()
.then(async keys => {
return await Promise.all(keys.map(key => storage.get(key))).then(v =>
v.filter((v): v is Collection => v !== null)
);
})
.catch(error => {
console.error('Failed to load collections', error);
return [];
});
};
const pageCollectionBaseAtom = atomWithObservable<Collection[]>(get => {
const currentWorkspacePromise = get(currentWorkspaceAtom);
const session = get(sessionAtom);
const localCRUD = get(localCollectionCRUDAtom);
const userId = session?.data?.user.id ?? null;
const useLocalStorage = userId === null;
return new Observable<Collection[]>(subscriber => {
// initial value
subscriber.next([]);
if (useLocalStorage) {
getCollections(localCRUD).then(collections => {
subscriber.next(collections);
});
const fn = () => {
getCollections(localCRUD).then(collections => {
subscriber.next(collections);
});
};
callbackSet.add(fn);
return () => {
callbackSet.delete(fn);
};
} else {
const group = new DisposableGroup();
currentWorkspacePromise.then(async currentWorkspace => {
const collectionsFromLocal = await getCollections(localCRUD);
const rootDoc = currentWorkspace.doc;
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
if (!settingMap.has(userId)) {
settingMap.set(
userId,
new YDoc({
guid: `${rootDoc.guid}:settings:${userId}`,
})
);
}
const settingDoc = settingMap.get(userId) as YDoc;
if (!settingDoc.isLoaded) {
settingDoc.load();
await settingDoc.whenLoaded;
}
const viewMap = settingDoc.getMap('view') as YMap<Collection>;
// sync local storage to doc
collectionsFromLocal.map(v => viewMap.set(v.id, v));
// delete from indexeddb
Promise.all(
collectionsFromLocal.map(async v => {
await localCRUD.delete(v.id);
})
).catch(error => {
console.error('Failed to delete collections from indexeddb', error);
});
const collectionsFromDoc: Collection[] = Array.from(viewMap.keys())
.map(key => viewMap.get(key))
.filter((v): v is Collection => !!v);
const collections = [...collectionsFromDoc];
subscriber.next(collections);
if (group.disposed) {
return;
}
const fn = () => {
const collectionsFromDoc: Collection[] = Array.from(viewMap.keys())
.map(key => viewMap.get(key))
.filter((v): v is Collection => !!v);
const collections = [...collectionsFromLocal, ...collectionsFromDoc];
subscriber.next(collections);
};
viewMap.observe(fn);
group.add(() => {
viewMap.unobserve(fn);
});
});
return () => {
group.dispose();
};
}
});
});
export const currentCollectionsAtom: CollectionsAtom = atom(
get => get(pageCollectionBaseAtom),
async (get, set, apply) => {
const collections = await get(pageCollectionBaseAtom);
let newCollections: Collection[];
if (typeof apply === 'function') {
newCollections = apply(collections);
} else {
newCollections = apply;
}
const session = get(sessionAtom);
const userId = session?.data?.user.id ?? null;
const useLocalStorage = userId === null;
const added = newCollections.filter(v => !collections.includes(v));
const removed = collections.filter(v => !newCollections.includes(v));
if (useLocalStorage) {
const localCRUD = get(localCollectionCRUDAtom);
await Promise.all([
...added.map(async v => {
await localCRUD.set(v.id, v);
}),
...removed.map(async v => {
await localCRUD.delete(v.id);
}),
]);
} else {
const currentWorkspace = await get(currentWorkspaceAtom);
const rootDoc = currentWorkspace.doc;
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
const settingDoc = settingMap.get(userId) as YDoc;
const viewMap = settingDoc.getMap('view') as YMap<Collection>;
await Promise.all([
...added.map(async v => {
viewMap.set(v.id, v);
}),
...removed.map(async v => {
viewMap.delete(v.id);
}),
]);
}
}
);