From 776d30613f9bfc9eed9e26334e58bddf73a7667f Mon Sep 17 00:00:00 2001 From: JimmFly Date: Tue, 7 Mar 2023 02:02:50 +0800 Subject: [PATCH] feat: add recentlyViewed (#1357) Co-authored-by: himself65 --- apps/web/src/atoms/index.ts | 30 +++++++- .../pure/quick-search-modal/Results.tsx | 71 +++++++++++++----- apps/web/src/hooks/__tests__/index.spec.tsx | 75 ++++++++++++++++++- apps/web/src/hooks/affine/use-recent-views.ts | 40 ++++++++++ .../workspace/[workspaceId]/[pageId].tsx | 2 + 5 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/hooks/affine/use-recent-views.ts diff --git a/apps/web/src/atoms/index.ts b/apps/web/src/atoms/index.ts index a24bab267..5aea3eaae 100644 --- a/apps/web/src/atoms/index.ts +++ b/apps/web/src/atoms/index.ts @@ -5,7 +5,6 @@ import { unstable_batchedUpdates } from 'react-dom'; import { WorkspacePlugins } from '../plugins'; import { RemWorkspace, RemWorkspaceFlavour } from '../shared'; - // workspace necessary atoms export const currentWorkspaceIdAtom = atom(null); export const currentPageIdAtom = atom(null); @@ -60,3 +59,32 @@ export const workspacesAtom = atom>(async get => { ); return workspaces.filter(workspace => workspace !== null) as RemWorkspace[]; }); + +type View = { title: string; id: string; mode: 'page' | 'edgeless' }; + +export type WorkspaceRecentViews = Record; + +export const workspaceRecentViewsAtom = atomWithStorage( + 'recentViews', + {} +); + +export const workspaceRecentViresWriteAtom = atom( + null, + (get, set, id, value) => { + const record = get(workspaceRecentViewsAtom); + if (Array.isArray(record[id])) { + const idx = record[id].findIndex(view => view.id === value.id); + if (idx !== -1) { + record[id].splice(idx, 1); + } + record[id] = [value, ...record[id]]; + } else { + record[id] = [value]; + } + + record[id] = record[id].slice(0, 3); + set(workspaceRecentViewsAtom, { ...record }); + return record[id]; + } +); diff --git a/apps/web/src/components/pure/quick-search-modal/Results.tsx b/apps/web/src/components/pure/quick-search-modal/Results.tsx index 9c0807259..cc629dc58 100644 --- a/apps/web/src/components/pure/quick-search-modal/Results.tsx +++ b/apps/web/src/components/pure/quick-search-modal/Results.tsx @@ -5,6 +5,7 @@ import { Command } from 'cmdk'; import { NextRouter } from 'next/router'; import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { useRecentlyViewed } from '../../../hooks/affine/use-recent-views'; import { useBlockSuiteWorkspaceHelper } from '../../../hooks/use-blocksuite-workspace-helper'; import { usePageMeta } from '../../../hooks/use-page-meta'; import { BlockSuiteWorkspace } from '../../../shared'; @@ -33,6 +34,7 @@ export const Results: React.FC = ({ assertExists(blockSuiteWorkspace.room); const List = useSwitchToConfig(blockSuiteWorkspace.room); const [results, setResults] = useState(new Map()); + const recentlyViewed = useRecentlyViewed(); const { t } = useTranslation(); useEffect(() => { setResults(blockSuiteWorkspace.search(query)); @@ -93,25 +95,56 @@ export const Results: React.FC = ({ ) ) : ( - - {List.map(link => { - return ( - { - onClose(); - router.push(link.href); - }} - > - - - {link.title} - - - ); - })} - +
+ + {recentlyViewed.map(recent => { + return ( + { + onClose(); + router.push({ + pathname: '/workspace/[workspaceId]/[pageId]', + query: { + workspaceId: blockSuiteWorkspace.room, + pageId: recent.id, + }, + }); + }} + > + + {recent.mode === 'edgeless' ? ( + + ) : ( + + )} + {recent.title} + + + ); + })} + + + {List.map(link => { + return ( + { + onClose(); + router.push(link.href); + }} + > + + + {link.title} + + + ); + })} + +
)} ); diff --git a/apps/web/src/hooks/__tests__/index.spec.tsx b/apps/web/src/hooks/__tests__/index.spec.tsx index 4dab6dbb5..5fab18712 100644 --- a/apps/web/src/hooks/__tests__/index.spec.tsx +++ b/apps/web/src/hooks/__tests__/index.spec.tsx @@ -13,10 +13,23 @@ import { useRouter } from 'next/router'; import routerMock from 'next-router-mock'; import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes'; import React from 'react'; -import { beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import { workspacesAtom } from '../../atoms'; -import { BlockSuiteWorkspace, RemWorkspaceFlavour } from '../../shared'; +import { + currentWorkspaceIdAtom, + jotaiWorkspacesAtom, + workspacesAtom, +} from '../../atoms'; +import { LocalPlugin } from '../../plugins/local'; +import { + BlockSuiteWorkspace, + LocalWorkspace, + RemWorkspaceFlavour, +} from '../../shared'; +import { + useRecentlyViewed, + useSyncRecentViewsWithRouter, +} from '../affine/use-recent-views'; import { currentWorkspaceAtom, useCurrentWorkspace, @@ -248,3 +261,59 @@ describe('useBlockSuiteWorkspaceName', () => { expect(blockSuiteWorkspace.meta.name).toBe('test 3'); }); }); + +describe('useRecentlyViewed', () => { + test('basic', async () => { + const { ProviderWrapper, store } = await getJotaiContext(); + const workspaceId = blockSuiteWorkspace.room as string; + const pageId = 'page0'; + store.set(jotaiWorkspacesAtom, [ + { + id: workspaceId, + flavour: RemWorkspaceFlavour.LOCAL, + }, + ]); + LocalPlugin.CRUD.get = vi.fn().mockResolvedValue({ + id: workspaceId, + flavour: RemWorkspaceFlavour.LOCAL, + blockSuiteWorkspace, + providers: [], + } satisfies LocalWorkspace); + store.set(currentWorkspaceIdAtom, blockSuiteWorkspace.room as string); + const workspace = await store.get(currentWorkspaceAtom); + expect(workspace?.id).toBe(blockSuiteWorkspace.room as string); + const currentHook = renderHook(() => useCurrentWorkspace(), { + wrapper: ProviderWrapper, + }); + expect(currentHook.result.current[0]?.id).toEqual(workspaceId); + await store.get(currentWorkspaceAtom); + const recentlyViewedHook = renderHook(() => useRecentlyViewed(), { + wrapper: ProviderWrapper, + }); + expect(recentlyViewedHook.result.current).toEqual([]); + const routerHook = renderHook(() => useRouter()); + await routerHook.result.current.push({ + pathname: '/workspace/[workspaceId]/[pageId]', + query: { + workspaceId, + pageId, + }, + }); + routerHook.rerender(); + const syncHook = renderHook( + router => useSyncRecentViewsWithRouter(router), + { + wrapper: ProviderWrapper, + initialProps: routerHook.result.current, + } + ); + syncHook.rerender(routerHook.result.current); + expect(recentlyViewedHook.result.current).toEqual([ + { + id: 'page0', + mode: 'page', + title: 'Untitled', + }, + ]); + }); +}); diff --git a/apps/web/src/hooks/affine/use-recent-views.ts b/apps/web/src/hooks/affine/use-recent-views.ts new file mode 100644 index 000000000..2465262d3 --- /dev/null +++ b/apps/web/src/hooks/affine/use-recent-views.ts @@ -0,0 +1,40 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { NextRouter } from 'next/router'; +import { useEffect } from 'react'; + +import { + workspaceRecentViewsAtom, + workspaceRecentViresWriteAtom, +} from '../../atoms'; +import { useCurrentWorkspace } from '../current/use-current-workspace'; +import { usePageMeta } from '../use-page-meta'; + +export function useRecentlyViewed() { + const [workspace] = useCurrentWorkspace(); + const workspaceId = workspace?.id || null; + const recentlyViewed = useAtomValue(workspaceRecentViewsAtom); + + if (!workspaceId) return []; + return recentlyViewed[workspaceId] ?? []; +} + +export function useSyncRecentViewsWithRouter(router: NextRouter) { + const [workspace] = useCurrentWorkspace(); + const workspaceId = workspace?.id || null; + const blockSuiteWorkspace = workspace?.blockSuiteWorkspace || null; + const pageId = router.query.pageId as string; + const set = useSetAtom(workspaceRecentViresWriteAtom); + const meta = usePageMeta(blockSuiteWorkspace).find( + meta => meta.id === pageId + ); + useEffect(() => { + if (!workspaceId) return; + if (pageId && meta) { + set(workspaceId, { + title: meta.title || 'Untitled', + id: pageId as string, + mode: meta.mode || 'page', + }); + } + }, [pageId, meta, workspaceId, set]); +} diff --git a/apps/web/src/pages/workspace/[workspaceId]/[pageId].tsx b/apps/web/src/pages/workspace/[workspaceId]/[pageId].tsx index 65306fa68..b1566fc30 100644 --- a/apps/web/src/pages/workspace/[workspaceId]/[pageId].tsx +++ b/apps/web/src/pages/workspace/[workspaceId]/[pageId].tsx @@ -3,6 +3,7 @@ import React, { useEffect } from 'react'; import { Unreachable } from '../../../components/affine/affine-error-eoundary'; import { PageLoading } from '../../../components/pure/loading'; +import { useSyncRecentViewsWithRouter } from '../../../hooks/affine/use-recent-views'; import { useCurrentPageId } from '../../../hooks/current/use-current-page-id'; import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { useSyncRouterWithCurrentWorkspaceAndPage } from '../../../hooks/use-sync-router-with-current-workspace-and-page'; @@ -27,6 +28,7 @@ function enableFullFlags(blockSuiteWorkspace: BlockSuiteWorkspace) { const WorkspaceDetail: React.FC = () => { const [pageId] = useCurrentPageId(); const [currentWorkspace] = useCurrentWorkspace(); + useSyncRecentViewsWithRouter(useRouter()); useEffect(() => { if (currentWorkspace) { enableFullFlags(currentWorkspace.blockSuiteWorkspace);