diff --git a/frontend/apps/desktop/src/components/document-actions-provider.tsx b/frontend/apps/desktop/src/components/document-actions-provider.tsx index 4c473fdb9..4937e5376 100644 --- a/frontend/apps/desktop/src/components/document-actions-provider.tsx +++ b/frontend/apps/desktop/src/components/document-actions-provider.tsx @@ -15,13 +15,17 @@ import {DocumentActionsProvider} from '@shm/shared/document-actions-context' import {queryResource} from '@shm/shared/models/queries' import {invalidateQueries, queryClient} from '@shm/shared/models/query-client' import {queryKeys} from '@shm/shared/models/query-keys' -import {hmId, pathMatches} from '@shm/shared/utils/entity-id-url' -import {useNavigate} from '@shm/shared/utils/navigation' +import {replaceRouteDocumentId} from '@shm/shared/routes' +import {hmId, latestId, pathMatches} from '@shm/shared/utils/entity-id-url' +import {useNavigate, useNavRoute} from '@shm/shared/utils/navigation' import {SizableText} from '@shm/ui/text' import {useAppDialog} from '@shm/ui/universal-dialog' import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid' import {PropsWithChildren, useCallback, useMemo} from 'react' +import {ResourceVisibility} from '@shm/shared/client/.generated/documents/v3alpha/documents_pb' +import {buildRestoreVersionChanges, getRestoreVersionGeneration} from '@shm/shared/utils/restore-document-version' +import {hmIdPathToEntityQueryPath} from '@shm/shared/utils/path-api' import {toast} from 'sonner' export function DesktopDocumentActionsProvider({children}: PropsWithChildren) { @@ -29,6 +33,7 @@ export function DesktopDocumentActionsProvider({children}: PropsWithChildren) { const myAccountIds = useMyAccountIds() const bookmarks = useBookmarks() const navigate = useNavigate() + const currentRoute = useNavRoute() const {exportDocument, openDirectory} = useAppContext() const {onCopyReference} = useUniversalAppContext() const universalClient = useUniversalClient() @@ -197,6 +202,77 @@ export function DesktopDocumentActionsProvider({children}: PropsWithChildren) { const getDraftId = useCallback((id: UnpackedHypermediaId) => getDraft(id)?.id, [getDraft]) + const onRestoreDocumentVersion = useCallback( + async (id: UnpackedHypermediaId, selectedVersion: HMDocument) => { + if (!selectedAccountId) { + toast.error('Select an account before restoring a version') + return + } + if (!universalClient.publishDocument) { + toast.error('Restore is not available in this client') + return + } + + try { + const targetId = latestId(id) + const latestResource = await queryClient.fetchQuery(queryResource(universalClient, targetId)) + const latestDocument = latestResource?.type === 'document' ? latestResource.document : null + if (!latestDocument?.version) throw new Error('Could not load the latest document version') + if (latestDocument.version === selectedVersion.version) { + toast.info('This version is already the latest version') + return + } + + const changes = buildRestoreVersionChanges(latestDocument, selectedVersion) + if (!changes.length) { + toast.info('This version matches the latest version') + return + } + + let capability = '' + if (selectedAccountId !== targetId.uid) { + const result = await universalClient.request('ListCapabilities', {targetId}) + const rawCapability = result.capabilities.find( + (cap: any) => cap.delegate === selectedAccountId && String(cap.role || '').toUpperCase() === 'WRITER', + ) + if (!rawCapability?.id) throw new Error('Could not find write capability for selected account') + capability = rawCapability.id + } + + await universalClient.publishDocument({ + signerAccountUid: selectedAccountId, + account: targetId.uid, + path: hmIdPathToEntityQueryPath(targetId.path || []), + baseVersion: latestDocument.version, + changes, + capability, + visibility: ResourceVisibility.UNSPECIFIED, + genesis: latestDocument.genesis, + generation: getRestoreVersionGeneration(latestDocument), + }) + + const draftId = getDraftId(targetId) + if (draftId) { + await client.drafts.delete.mutate(draftId) + } + + invalidateQueries([queryKeys.ENTITY]) + invalidateQueries([queryKeys.ACTIVITY_FEED]) + invalidateQueries([queryKeys.DRAFTS_LIST]) + invalidateQueries([queryKeys.DRAFTS_LIST_ACCOUNT]) + if (draftId) invalidateQueries([queryKeys.DRAFT, draftId]) + + navigate(replaceRouteDocumentId(currentRoute, targetId)) + toast.success('Document restored successfully') + } catch (error) { + console.error('Failed to restore document version:', error) + toast.error(error instanceof Error ? error.message : 'Failed to restore document version') + throw error + } + }, + [currentRoute, getDraftId, navigate, selectedAccountId, universalClient], + ) + const value = useMemo( () => ({ selectedAccountUid: selectedAccountId ?? undefined, @@ -208,6 +284,7 @@ export function DesktopDocumentActionsProvider({children}: PropsWithChildren) { onDeleteDocument, onBranchDocument, onDuplicateDocument, + onRestoreDocumentVersion, onExportDocument, onCopyLink, getDraftId, @@ -223,6 +300,7 @@ export function DesktopDocumentActionsProvider({children}: PropsWithChildren) { onDeleteDocument, onBranchDocument, onDuplicateDocument, + onRestoreDocumentVersion, onExportDocument, onCopyLink, getDraftId, diff --git a/frontend/apps/desktop/src/components/editing-toolbar.tsx b/frontend/apps/desktop/src/components/editing-toolbar.tsx index 22e4f15e4..3aeaad686 100644 --- a/frontend/apps/desktop/src/components/editing-toolbar.tsx +++ b/frontend/apps/desktop/src/components/editing-toolbar.tsx @@ -15,6 +15,7 @@ import { DraftActionsToolbar as SharedDraftActionsToolbar, EditingDocToolsRight as SharedEditingDocToolsRight, } from '@shm/ui/editing-toolbar' +import {createDocumentVersionsPanelRoute} from '@shm/ui/document-versions-panel' import {MenuItemType} from '@shm/ui/options-dropdown' import {toast} from '@shm/ui/toast' import {useCallback, useMemo} from 'react' @@ -103,7 +104,7 @@ export function useDesktopToolbarCallbacks(docId: UnpackedHypermediaId): { navigate({ key: 'document', id, - panel: {key: 'activity', id, filterEventType: ['Ref']}, + panel: createDocumentVersionsPanelRoute(id), } as any) }, }), diff --git a/frontend/apps/desktop/src/pages/desktop-resource.tsx b/frontend/apps/desktop/src/pages/desktop-resource.tsx index c9bf98b46..574db7293 100644 --- a/frontend/apps/desktop/src/pages/desktop-resource.tsx +++ b/frontend/apps/desktop/src/pages/desktop-resource.tsx @@ -74,6 +74,7 @@ import {useNavigationDispatch, useNavRoute} from '@shm/shared/utils/navigation' import {entityQueryPathToHmIdPath} from '@shm/shared/utils/path-api' import {CloudOff, Download, Trash, UploadCloud} from '@shm/ui/icons' import {copyUrlToClipboardWithFeedback} from '@shm/ui/copy-to-clipboard' +import {createDocumentVersionsPanelRoute} from '@shm/ui/document-versions-panel' import {MenuItemType} from '@shm/ui/options-dropdown' import {ResourcePage} from '@shm/ui/resource-page-common' import {SizableText} from '@shm/ui/text' @@ -719,7 +720,7 @@ export default function DesktopResourcePage() { replace({ key: 'document', id: docId, - panel: {key: 'activity', id: docId, filterEventType: ['Ref']}, + panel: createDocumentVersionsPanelRoute(docId), }) }, }) diff --git a/frontend/apps/desktop/src/utils/__tests__/publish-document.test.ts b/frontend/apps/desktop/src/utils/__tests__/publish-document.test.ts index 044778cfc..700ec20de 100644 --- a/frontend/apps/desktop/src/utils/__tests__/publish-document.test.ts +++ b/frontend/apps/desktop/src/utils/__tests__/publish-document.test.ts @@ -55,6 +55,32 @@ describe('shouldUseDaemonCreateDocumentChange', () => { }), ).toBe(false) }) + + it('keeps explicit-generation publishes on the seed client path', () => { + expect( + shouldUseDaemonCreateDocumentChange({ + signerAccountUid: 'alice', + account: 'alice', + path: '/foo', + baseVersion: 'bafy-base', + genesis: 'bafy-genesis', + generation: 123, + changes: [], + }), + ).toBe(false) + }) + + it('still uses daemon createDocumentChange for brand-new home documents with explicit generation', () => { + expect( + shouldUseDaemonCreateDocumentChange({ + signerAccountUid: 'alice', + account: 'alice', + path: '', + generation: 123, + changes: [], + }), + ).toBe(true) + }) }) describe('createDocumentChangeRequest', () => { @@ -185,4 +211,45 @@ describe('publishDesktopDocument', () => { signer, ) }) + + it('passes explicit generation through the seed client path', async () => { + const createDocumentChange = vi.fn().mockResolvedValue(undefined) + const publishDocument = vi.fn().mockResolvedValue(undefined) + const signer = { + getPublicKey: vi.fn(async () => new Uint8Array([1])), + sign: vi.fn(async () => new Uint8Array([2])), + } + const getSigner = vi.fn(() => signer) + + await publishDesktopDocument( + { + createDocumentChange, + publishDocument, + getSigner, + }, + { + signerAccountUid: 'alice', + account: 'alice', + path: '/foo', + baseVersion: 'bafy-base', + genesis: 'bafy-genesis', + generation: 123, + changes: [{op: {case: 'setMetadata', value: {key: 'name', value: 'Foo'}}}], + }, + ) + + expect(createDocumentChange).not.toHaveBeenCalled() + expect(getSigner).toHaveBeenCalledWith('alice') + expect(publishDocument).toHaveBeenCalledWith( + { + account: 'alice', + path: '/foo', + baseVersion: 'bafy-base', + genesis: 'bafy-genesis', + generation: 123, + changes: [{op: {case: 'setMetadata', value: {key: 'name', value: 'Foo'}}}], + }, + signer, + ) + }) }) diff --git a/frontend/apps/desktop/src/utils/publish-document.ts b/frontend/apps/desktop/src/utils/publish-document.ts index fdda0850c..0b690e0ff 100644 --- a/frontend/apps/desktop/src/utils/publish-document.ts +++ b/frontend/apps/desktop/src/utils/publish-document.ts @@ -20,6 +20,10 @@ type PublishDesktopDocumentDeps = { /** Uses the daemon publish path for updates to existing published documents and new root documents. */ export function shouldUseDaemonCreateDocumentChange(input: PublishDocumentInput): boolean { + // The daemon CreateDocumentChange request cannot carry an explicit generation. + // Keep existing-document publishes on the seed client path so restores stay in the latest generation. + if (input.generation != null && input.baseVersion && input.genesis) return false + // Use daemon for updates to existing documents (has baseVersion and genesis), // and for new root documents (path is empty) which need genesis creation // via ensureProfileGenesis — PrepareChange cannot handle this. diff --git a/frontend/apps/web/app/document-edit/web-restore-document-version.test.ts b/frontend/apps/web/app/document-edit/web-restore-document-version.test.ts new file mode 100644 index 000000000..3abbc4954 --- /dev/null +++ b/frontend/apps/web/app/document-edit/web-restore-document-version.test.ts @@ -0,0 +1,132 @@ +import 'fake-indexeddb/auto' +import {signDocumentChange} from '@seed-hypermedia/client' +import {indexedDB} from 'fake-indexeddb' +import type {HMBlockNode, HMDocument, HMSigner, UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {restoreWebDocumentVersion} from './web-restore-document-version' +import {_resetWebDocDraftDBForTesting, getWebDocDraft, putWebDocDraft} from './web-draft-db' + +vi.mock('@seed-hypermedia/client', async () => { + const actual = await vi.importActual('@seed-hypermedia/client') + return { + ...actual, + signDocumentChange: vi.fn(async () => ({ + changeCid: {toString: () => 'restored-version'}, + publishInput: {blobs: []}, + })), + } +}) + +const DB_NAME = 'web-doc-drafts-01' + +function dropDB(): Promise { + return new Promise((resolve) => { + const req = indexedDB.deleteDatabase(DB_NAME) + req.onsuccess = () => resolve() + req.onerror = () => resolve() + }) +} + +function id(): UnpackedHypermediaId { + return { + uid: 'z1', + path: ['doc'], + id: 'hm://z1/doc', + version: null, + latest: false, + blockRef: null, + blockRange: null, + hostname: null, + scheme: 'hm', + } as UnpackedHypermediaId +} + +function node(blockId: string, text: string): HMBlockNode { + return {block: {id: blockId, type: 'Paragraph', text} as any, children: []} +} + +function doc(overrides: Partial): HMDocument { + return { + account: 'z1', + path: '/doc', + version: 'latest-version', + authors: [], + content: [], + metadata: {}, + visibility: 'PUBLIC', + createTime: '', + updateTime: '', + genesis: 'genesis', + generationInfo: {generation: 5n}, + ...overrides, + } as HMDocument +} + +describe('restoreWebDocumentVersion', () => { + beforeEach(async () => { + vi.mocked(signDocumentChange).mockClear() + _resetWebDocDraftDBForTesting() + await dropDB() + _resetWebDocDraftDBForTesting() + }) + + it('publishes selected content on top of latest and removes the current web draft', async () => { + const targetId = id() + await putWebDocDraft({ + draftId: 'draft-1', + docId: targetId.id, + signingAccountId: 'z1', + content: [], + metadata: {}, + deps: ['latest-version'], + navigation: null, + locationUid: null, + locationPath: null, + editUid: 'z1', + editPath: ['doc'], + cursorPosition: null, + }) + + const latest = doc({content: [node('a', 'latest'), node('b', 'delete')], metadata: {name: 'Latest'}}) + const selected = doc({version: 'old-version', content: [node('a', 'old')], metadata: {name: 'Old'}}) + const restored = doc({version: 'restored-version', content: selected.content, metadata: selected.metadata}) + const request = vi.fn(async (key: string, input: any) => { + if (key === 'Resource') { + return input.version === 'restored-version' + ? {type: 'document', document: restored, id: targetId} + : {type: 'document', document: latest, id: targetId} + } + if (key === 'PrepareDocumentChange') return {unsignedChange: new Uint8Array([1])} + throw new Error(`unexpected request ${key}`) + }) + const publish = vi.fn(async () => ({})) + + const result = await restoreWebDocumentVersion( + {targetId, selectedVersion: selected, signerAccountUid: 'z1'}, + { + client: {request, publish} as any, + getSigner: () => ({getPublicKey: vi.fn(), sign: vi.fn()}) as unknown as HMSigner, + }, + ) + + expect(result.version).toBe('restored-version') + expect(publish).toHaveBeenCalledTimes(1) + const prepareCall = request.mock.calls.find(([key]) => key === 'PrepareDocumentChange') + expect(prepareCall?.[1].baseVersion).toBe('latest-version') + expect(signDocumentChange).toHaveBeenCalledWith( + expect.objectContaining({ + genesis: 'genesis', + generation: 5n, + }), + expect.anything(), + ) + expect(prepareCall?.[1].changes.map((change: any) => change.op.case)).toEqual([ + 'setAttribute', + 'deleteBlock', + 'deleteBlock', + 'moveBlock', + 'replaceBlock', + ]) + await expect(getWebDocDraft('draft-1')).resolves.toBeNull() + }) +}) diff --git a/frontend/apps/web/app/document-edit/web-restore-document-version.ts b/frontend/apps/web/app/document-edit/web-restore-document-version.ts new file mode 100644 index 000000000..e68d5c964 --- /dev/null +++ b/frontend/apps/web/app/document-edit/web-restore-document-version.ts @@ -0,0 +1,91 @@ +import {signDocumentChange} from '@seed-hypermedia/client' +import type {HMDocument, HMSigner, UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' +import {ResourceVisibility} from '@shm/shared/client/.generated/documents/v3alpha/documents_pb' +import {invalidateAfterPublish} from '@shm/shared/models/post-publish-cache' +import {invalidateQueries, refetchQueriesByKey} from '@shm/shared/models/query-client' +import {queryKeys} from '@shm/shared/models/query-keys' +import type {UniversalClient} from '@shm/shared/universal-client' +import {latestId} from '@shm/shared/utils/entity-id-url' +import {hmIdPathToEntityQueryPath} from '@shm/shared/utils/path-api' +import {buildRestoreVersionChanges, getRestoreVersionGeneration} from '@shm/shared/utils/restore-document-version' +import {deleteWebDocDraft, getLatestWebDocDraftForDoc} from './web-draft-db' + +export type RestoreWebDocumentVersionInput = { + targetId: UnpackedHypermediaId + selectedVersion: HMDocument + signerAccountUid: string + capabilityCid?: string +} + +export type RestoreWebDocumentVersionDeps = { + client: UniversalClient + getSigner: (accountUid: string) => HMSigner +} + +/** Restores a document version on web without going through the draft publish flow. */ +export async function restoreWebDocumentVersion( + input: RestoreWebDocumentVersionInput, + deps: RestoreWebDocumentVersionDeps, +): Promise { + const targetId = latestId(input.targetId) + const latestResource = await deps.client.request('Resource', targetId) + const latestDocument = latestResource.type === 'document' ? latestResource.document : null + if (!latestDocument?.version) throw new Error('Could not load the latest document version') + if (latestDocument.version === input.selectedVersion.version) + throw new Error('This version is already the latest version') + + const changes = buildRestoreVersionChanges(latestDocument, input.selectedVersion) + if (!changes.length) throw new Error('This version matches the latest version') + + const path = hmIdPathToEntityQueryPath(targetId.path || []) + const visibility = ResourceVisibility.UNSPECIFIED + const prepareResult = (await deps.client.request( + 'PrepareDocumentChange' as any, + { + account: targetId.uid, + path, + baseVersion: latestDocument.version, + changes: changes as any, + capability: input.capabilityCid ?? '', + visibility, + } as any, + )) as any + + const {changeCid, publishInput} = await signDocumentChange( + { + account: targetId.uid, + path, + unsignedChange: prepareResult.unsignedChange, + genesis: latestDocument.genesis, + generation: getRestoreVersionGeneration(latestDocument), + capability: input.capabilityCid ?? '', + visibility, + }, + deps.getSigner(input.signerAccountUid), + ) + + await deps.client.publish(publishInput) + + const after = await deps.client.request('Resource', { + ...targetId, + version: changeCid.toString(), + latest: false, + }) + if (after.type !== 'document') throw new Error('post-restore resource is not a document') + + const draft = await getLatestWebDocDraftForDoc(targetId.id) + if (draft) { + await deleteWebDocDraft(draft.draftId) + } + + invalidateAfterPublish(targetId, after.document) + invalidateQueries([queryKeys.ACTIVITY_FEED]) + invalidateQueries(['web-doc-draft', targetId.id]) + try { + await refetchQueriesByKey(['web-doc-draft', targetId.id]) + } catch { + // Non-critical: the stale draft cache will clear on the next mount. + } + + return after.document +} diff --git a/frontend/apps/web/app/routes/$.tsx b/frontend/apps/web/app/routes/$.tsx index 470467487..cdefa2de8 100644 --- a/frontend/apps/web/app/routes/$.tsx +++ b/frontend/apps/web/app/routes/$.tsx @@ -42,6 +42,7 @@ import {useNavigationState} from '@shm/shared/utils/navigation' import {InspectIpfsPage} from '@shm/ui/inspect-ipfs-page' import {useTx} from '@shm/shared/translation' import {SizableText} from '@shm/ui/text' +import {shouldRevalidateDocumentRoute} from './revalidation' // Extended payload with view term and panel param for page routing type ExtendedSitePayload = SiteDocumentPayload & { @@ -220,22 +221,7 @@ export function shouldRevalidate({ nextUrl: URL defaultShouldRevalidate: boolean }) { - // Different pathname always revalidates - if (currentUrl.pathname !== nextUrl.pathname) { - return defaultShouldRevalidate - } - - // Same pathname — check if data-affecting params changed - const currentV = currentUrl.searchParams.get('v') - const nextV = nextUrl.searchParams.get('v') - const currentL = currentUrl.searchParams.get('l') - const nextL = nextUrl.searchParams.get('l') - - if (currentV === nextV && currentL === nextL) { - return false // Only cosmetic params (panel, view) changed - } - - return defaultShouldRevalidate + return shouldRevalidateDocumentRoute({currentUrl, nextUrl, defaultShouldRevalidate}) } export const loader = async ({params, request}: {params: Params; request: Request}) => { diff --git a/frontend/apps/web/app/routes/_index.tsx b/frontend/apps/web/app/routes/_index.tsx index 3edc326ff..a11d82306 100644 --- a/frontend/apps/web/app/routes/_index.tsx +++ b/frontend/apps/web/app/routes/_index.tsx @@ -9,6 +9,7 @@ import {Params, useLoaderData} from '@remix-run/react' import {UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' import {createDocumentNavRoute, ViewRouteKey} from '@shm/shared' import {DaemonErrorPage, loader as loaderFn, meta as metaFn} from './$' +import {shouldRevalidateDocumentRoute} from './revalidation' export const loader = async ({params, request}: {params: Params; request: Request}) => { return await loaderFn({ @@ -17,6 +18,18 @@ export const loader = async ({params, request}: {params: Params; request: Reques }) } +export function shouldRevalidate({ + currentUrl, + nextUrl, + defaultShouldRevalidate, +}: { + currentUrl: URL + nextUrl: URL + defaultShouldRevalidate: boolean +}) { + return shouldRevalidateDocumentRoute({currentUrl, nextUrl, defaultShouldRevalidate}) +} + type ExtendedSitePayload = SiteDocumentPayload & { viewTerm?: ViewRouteKey | null panelParam?: string | null // Supports extended format like "comments/BLOCKID" or "comments/COMMENT_ID" diff --git a/frontend/apps/web/app/routes/revalidation.ts b/frontend/apps/web/app/routes/revalidation.ts new file mode 100644 index 000000000..a8b939f87 --- /dev/null +++ b/frontend/apps/web/app/routes/revalidation.ts @@ -0,0 +1,25 @@ +/** Decides whether the document route loader should re-run for a URL transition. */ +export function shouldRevalidateDocumentRoute({ + currentUrl, + nextUrl, + defaultShouldRevalidate, +}: { + currentUrl: URL + nextUrl: URL + defaultShouldRevalidate: boolean +}) { + if (currentUrl.pathname !== nextUrl.pathname) { + return true + } + + const currentV = currentUrl.searchParams.get('v') + const nextV = nextUrl.searchParams.get('v') + const currentL = currentUrl.searchParams.get('l') + const nextL = nextUrl.searchParams.get('l') + + if (currentV === nextV && currentL === nextL) { + return false + } + + return true +} diff --git a/frontend/apps/web/app/routes/route-revalidation.test.ts b/frontend/apps/web/app/routes/route-revalidation.test.ts new file mode 100644 index 000000000..bd3101563 --- /dev/null +++ b/frontend/apps/web/app/routes/route-revalidation.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, it} from 'vitest' +import {readFileSync} from 'node:fs' +import {join} from 'node:path' +import {shouldRevalidateDocumentRoute} from './revalidation' + +function url(path: string) { + return new URL(path, 'https://example.com') +} + +describe('document route revalidation', () => { + it('revalidates when only the requested document version changes', () => { + expect( + shouldRevalidateDocumentRoute({ + currentUrl: url('/doc?panel=activity/versions'), + nextUrl: url('/doc?v=version-1&panel=activity/versions'), + defaultShouldRevalidate: false, + }), + ).toBe(true) + }) + + it('does not revalidate for panel-only changes', () => { + expect( + shouldRevalidateDocumentRoute({ + currentUrl: url('/doc?v=version-1'), + nextUrl: url('/doc?v=version-1&panel=activity/versions'), + defaultShouldRevalidate: true, + }), + ).toBe(false) + }) + + it('revalidates when moving from an activity URL to a versioned document URL', () => { + expect( + shouldRevalidateDocumentRoute({ + currentUrl: url('/doc/:activity/versions'), + nextUrl: url('/doc?v=version-1'), + defaultShouldRevalidate: false, + }), + ).toBe(true) + }) + + it('uses the same version revalidation on the home document route', () => { + const indexRouteSource = readFileSync(join(import.meta.dirname, '_index.tsx'), 'utf8') + + expect(indexRouteSource).toContain('shouldRevalidateDocumentRoute') + }) +}) diff --git a/frontend/apps/web/app/web-resource-page.test.ts b/frontend/apps/web/app/web-resource-page.test.ts new file mode 100644 index 000000000..ac5fb458a --- /dev/null +++ b/frontend/apps/web/app/web-resource-page.test.ts @@ -0,0 +1,13 @@ +import {readFileSync} from 'node:fs' +import {join} from 'node:path' +import {describe, expect, it} from 'vitest' + +describe('WebResourcePage restore action wiring', () => { + it('only exposes the restore action when the web user can edit', () => { + const source = readFileSync(join(__dirname, 'web-resource-page.tsx'), 'utf8') + + expect(source).toContain( + 'onRestoreDocumentVersion={effectiveCanEdit && signingAccountId ? onRestoreDocumentVersion : undefined}', + ) + }) +}) diff --git a/frontend/apps/web/app/web-resource-page.tsx b/frontend/apps/web/app/web-resource-page.tsx index 50e97a356..563663510 100644 --- a/frontend/apps/web/app/web-resource-page.tsx +++ b/frontend/apps/web/app/web-resource-page.tsx @@ -1,54 +1,50 @@ -import {HMExistingDraft, UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' +import {HMDocument, HMExistingDraft, UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' import {hmId, useJoinSite, useUniversalAppContext, useUniversalClient} from '@shm/shared' import {CommentsProvider, InlineEditCommentProps} from '@shm/shared/comments-service-provider' -import {canCreateChildDocuments} from '@shm/shared/document-utils' import {NOTIFY_SERVICE_HOST} from '@shm/shared/constants' +import {DocumentActionsProvider} from '@shm/shared/document-actions-context' import type {DocumentContentProps} from '@shm/shared/document-content-props' +import {canCreateChildDocuments} from '@shm/shared/document-utils' import {type EditorAccessor} from '@shm/shared/models/document-machine' import {useResource} from '@shm/shared/models/entity' import {QueryBlockDraftsProvider} from '@shm/shared/query-block-drafts-context' +import {replaceRouteDocumentId} from '@shm/shared/routes' import {getDraftPlaceholderParentId} from '@shm/shared/utils/breadcrumbs' import {useCommentNavigation} from '@shm/shared/utils/comment-navigation' -import {createWebHMUrl} from '@shm/shared/utils/entity-id-url' +import {createWebHMUrl, latestId} from '@shm/shared/utils/entity-id-url' import {useNavRoute, useNavigate} from '@shm/shared/utils/navigation' -import {entityQueryPathToHmIdPath} from '@shm/shared/utils/path-api' import {pathNameify} from '@shm/shared/utils/path' +import {entityQueryPathToHmIdPath} from '@shm/shared/utils/path-api' import {computeInlineDraftPublishPath} from '@shm/shared/utils/publish-paths' import {getDraftReturnParentId, isReservedLazyDraftId} from '@shm/shared/utils/reserved-draft-ids' -import {useQuery} from '@tanstack/react-query' +import {createDocumentVersionsPanelRoute} from '@shm/ui/document-versions-panel' +import {EditingDocToolsRight, type EditingToolbarCallbacks} from '@shm/ui/editing-toolbar' +import {Trash} from '@shm/ui/icons' import {InlineSubscribeBox} from '@shm/ui/inline-subscribe-box' import {InspectorPage} from '@shm/ui/inspector-page' -import {DocumentActionsProvider} from '@shm/shared/document-actions-context' +import type {MenuItemType} from '@shm/ui/options-dropdown' import {CommentEditorProps, ResourcePage} from '@shm/ui/resource-page-common' import {Spinner} from '@shm/ui/spinner' import {toast} from '@shm/ui/toast' import {useAppDialog} from '@shm/ui/universal-dialog' -import {EditingDocToolsRight, type EditingToolbarCallbacks} from '@shm/ui/editing-toolbar' -import {Trash} from '@shm/ui/icons' -import type {MenuItemType} from '@shm/ui/options-dropdown' -import {lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState} from 'react' -import { - EditProfileDialog, - LogoutButton, - getCurrentSigner, - useCreateAccount, - useLocalKeyPair, - useVaultSuccessDialog, -} from './auth' +import {useQuery} from '@tanstack/react-query' +import {Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {EditProfileDialog, LogoutButton, useCreateAccount, useLocalKeyPair, useVaultSuccessDialog} from './auth' import {preloadCommenting} from './client-lazy' -import {setPendingIntent} from './local-db' -import {PageFooter} from './page-footer' -import {processPendingIntent} from './pending-intent' -import {WebHeaderActions, WebSitePageShell, useWebCreateDocumentMenuItem, useWebMenuItems} from './web-utils' import {useWebCanEdit} from './document-edit/use-web-can-edit' import {createWebDocumentMachine} from './document-edit/web-document-actors' import {WebDraftActionsProvider} from './document-edit/web-draft-actions-provider' -import {WebQueryBlockDraftSlot} from './document-edit/web-query-block-draft-slot' -import {cleanupOldWebDocDrafts, getLatestWebDocDraftForDoc, getWebDocDraft} from './document-edit/web-draft-db' -import {makeWebFileUpload} from './document-edit/web-image-upload' import {WebDraftBreadcrumbProvider} from './document-edit/web-draft-breadcrumb-provider' +import {cleanupOldWebDocDrafts, getLatestWebDocDraftForDoc, getWebDocDraft} from './document-edit/web-draft-db' import {getWebDraftShellId, shouldUseLocalWebDraftShell} from './document-edit/web-draft-shell' +import {makeWebFileUpload} from './document-edit/web-image-upload' +import {WebQueryBlockDraftSlot} from './document-edit/web-query-block-draft-slot' +import {restoreWebDocumentVersion} from './document-edit/web-restore-document-version' +import {setPendingIntent} from './local-db' +import {PageFooter} from './page-footer' +import {processPendingIntent} from './pending-intent' import {useWebDeleteDocumentDialog} from './web-delete-document-dialog' +import {WebHeaderActions, WebSitePageShell, useWebCreateDocumentMenuItem, useWebMenuItems} from './web-utils' /** Lazy-loaded inline comment editor — avoids pulling the full editor bundle eagerly. */ const LazyWebInlineEditor = lazy(() => import('./commenting').then((mod) => ({default: mod.WebInlineEditBox}))) @@ -306,7 +302,7 @@ export function WebResourcePage({docId, CommentEditor, ssrContentHTML}: WebResou navigate({ key: 'document', id, - panel: {key: 'activity', id, filterEventType: ['Ref']}, + panel: createDocumentVersionsPanelRoute(id), } as any) }, }), @@ -391,6 +387,41 @@ export function WebResourcePage({docId, CommentEditor, ssrContentHTML}: WebResou replaceRoute, }) + const onRestoreDocumentVersion = useCallback( + async (id: UnpackedHypermediaId, selectedVersion: HMDocument) => { + if (!effectiveCanEdit || !signingAccountId) { + toast.error('You do not have permission to restore this document') + return + } + if (!universalClient.getSigner) { + toast.error('Restore is not available in this client') + return + } + + try { + await restoreWebDocumentVersion( + { + targetId: id, + selectedVersion, + signerAccountUid: signingAccountId, + capabilityCid: effectiveCapabilityCid, + }, + { + client: universalClient, + getSigner: (accountUid) => universalClient.getSigner!(accountUid), + }, + ) + replaceRouteRef.current(replaceRouteDocumentId(route, latestId(id))) + toast.success('Document restored successfully') + } catch (error) { + console.error('Failed to restore document version:', error) + toast.error(error instanceof Error ? error.message : 'Failed to restore document version') + throw error + } + }, + [effectiveCanEdit, effectiveCapabilityCid, route, signingAccountId, universalClient], + ) + const [lastCreatedDraftId, setLastCreatedDraftId] = useState(null) const canCreateInlineDraft = !useLocalDraftShell && canCreateChildDocs @@ -406,6 +437,7 @@ export function WebResourcePage({docId, CommentEditor, ssrContentHTML}: WebResou selectedAccountUid={signingAccountId ?? undefined} myAccountIds={signingAccountId ? [signingAccountId] : []} onDeleteDocument={onDeleteDocument} + onRestoreDocumentVersion={effectiveCanEdit && signingAccountId ? onRestoreDocumentVersion : undefined} > void) => void onBranchDocument?: (id: UnpackedHypermediaId) => void onDuplicateDocument?: (id: UnpackedHypermediaId) => void + onRestoreDocumentVersion?: (id: UnpackedHypermediaId, selectedVersion: HMDocument) => Promise | void onExportDocument?: (doc: HMDocument) => void onCopyLink?: (id: UnpackedHypermediaId) => void @@ -39,6 +40,7 @@ export function DocumentActionsProvider({children, ...value}: PropsWithChildren< value.onDeleteDocument, value.onBranchDocument, value.onDuplicateDocument, + value.onRestoreDocumentVersion, value.onExportDocument, value.onCopyLink, value.getDraftId, diff --git a/frontend/packages/shared/src/utils/document-changes.ts b/frontend/packages/shared/src/utils/document-changes.ts index 3d42c51ca..434f68011 100644 --- a/frontend/packages/shared/src/utils/document-changes.ts +++ b/frontend/packages/shared/src/utils/document-changes.ts @@ -1,3 +1,4 @@ +import {Empty} from '@bufbuild/protobuf' import {EditorBlock} from '@seed-hypermedia/client/editor-types' import {editorBlockToHMBlock} from '@seed-hypermedia/client/editorblock-to-hmblock' import {HMBlock, HMBlockNode, HMMetadata, HMQuery} from '@seed-hypermedia/client/hm-types' @@ -16,38 +17,57 @@ export type BlocksMapItem = { block: HMBlock } -export function getDocAttributeChanges(metadata: HMMetadata) { - const changes = [] - if (metadata.name !== undefined) changes.push(docAttributeChangeString(['name'], metadata.name)) - if (metadata.summary !== undefined) changes.push(docAttributeChangeString(['summary'], metadata.summary)) - if (metadata.icon !== undefined) changes.push(docAttributeChangeString(['icon'], metadata.icon)) - if (metadata.thumbnail !== undefined) changes.push(docAttributeChangeString(['thumbnail'], metadata.thumbnail)) - if (metadata.cover !== undefined) changes.push(docAttributeChangeString(['cover'], metadata.cover)) - if (metadata.siteUrl !== undefined) changes.push(docAttributeChangeString(['siteUrl'], metadata.siteUrl)) - if (metadata.layout !== undefined) changes.push(docAttributeChangeString(['layout'], metadata.layout)) - if (metadata.displayAuthor !== undefined) - changes.push(docAttributeChangeString(['displayAuthor'], metadata.displayAuthor)) - if (metadata.displayPublishTime !== undefined) - changes.push(docAttributeChangeString(['displayPublishTime'], metadata.displayPublishTime)) - if (metadata.seedExperimentalLogo !== undefined) - changes.push(docAttributeChangeString(['seedExperimentalLogo'], metadata.seedExperimentalLogo)) - if (metadata.seedExperimentalHomeOrder !== undefined) - changes.push(docAttributeChangeString(['seedExperimentalHomeOrder'], metadata.seedExperimentalHomeOrder)) - if (metadata.showOutline !== undefined) changes.push(docAttributeChangeBool(['showOutline'], metadata.showOutline)) - if (metadata.theme !== undefined) { - if (metadata.theme.headerLayout !== undefined) - changes.push(docAttributeChangeString(['theme', 'headerLayout'], metadata.theme.headerLayout)) +export function getDocAttributeChanges(metadata: HMMetadata, baseMetadata?: HMMetadata) { + return getAttributeChangesForObject( + metadata as Record, + baseMetadata as Record | undefined, + ) +} + +function getAttributeChangesForObject( + metadata: Record, + baseMetadata: Record | undefined, +) { + const changes: DocumentChange[] = [] + const keys = new Set([...Object.keys(baseMetadata ?? {}), ...Object.keys(metadata ?? {})]) + for (const key of Array.from(keys)) { + pushAttributeChanges(changes, [key], metadata?.[key], baseMetadata?.[key]) } - if (metadata.contentWidth !== undefined) { - changes.push(docAttributeChangeString(['contentWidth'], metadata.contentWidth)) + return changes +} + +function pushAttributeChanges(changes: DocumentChange[], key: string[], value: unknown, baseValue: unknown) { + if (isPlainObject(value) || isPlainObject(baseValue)) { + const valueObj = isPlainObject(value) ? value : undefined + const baseObj = isPlainObject(baseValue) ? baseValue : undefined + const keys = new Set([...Object.keys(baseObj ?? {}), ...Object.keys(valueObj ?? {})]) + for (const childKey of Array.from(keys)) { + pushAttributeChanges(changes, [...key, childKey], valueObj?.[childKey], baseObj?.[childKey]) + } + return } - if (metadata.childrenType !== undefined) { - changes.push(docAttributeChangeString(['childrenType'], metadata.childrenType || '')) + + if (baseValue !== undefined && value === undefined) { + changes.push(docAttributeChangeNull(key)) + return } - if (metadata.showActivity !== undefined) { - changes.push(docAttributeChangeBool(['showActivity'], metadata.showActivity)) + if (baseValue !== undefined && value === baseValue) return + + if (typeof value === 'string') { + changes.push(docAttributeChangeString(key, value)) + } else if (typeof value === 'boolean') { + changes.push(docAttributeChangeBool(key, value)) + } else if (typeof value === 'number' && Number.isInteger(value)) { + changes.push(docAttributeChangeInt(key, value)) + } else if (typeof value === 'bigint') { + changes.push(docAttributeChangeInt(key, value)) + } else if (value === null) { + changes.push(docAttributeChangeNull(key)) } - return changes +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) } type PrimitiveValue = string | number | boolean | null | undefined @@ -78,21 +98,37 @@ function docAttributeChangeString(key: string[], value: string) { }, }) } -// function docAttributeChangeInt(key: string[], value: number) { -// return new DocumentChange({ -// op: { -// case: 'setAttribute', -// value: new DocumentChange_SetAttribute({ -// blockId: '', -// key, -// value: { -// case: 'intValue', -// value: BigInt(value), -// }, -// }), -// }, -// }) -// } +function docAttributeChangeInt(key: string[], value: number | bigint) { + return new DocumentChange({ + op: { + case: 'setAttribute', + value: new DocumentChange_SetAttribute({ + blockId: '', + key, + value: { + case: 'intValue', + value: BigInt(value), + }, + }), + }, + }) +} + +function docAttributeChangeNull(key: string[]) { + return new DocumentChange({ + op: { + case: 'setAttribute', + value: new DocumentChange_SetAttribute({ + blockId: '', + key, + value: { + case: 'nullValue', + value: new Empty(), + }, + }), + }, + }) +} function docAttributeChangeBool(key: string[], value: boolean) { return new DocumentChange({ op: { diff --git a/frontend/packages/shared/src/utils/restore-document-version.test.ts b/frontend/packages/shared/src/utils/restore-document-version.test.ts new file mode 100644 index 000000000..ce59a8b3c --- /dev/null +++ b/frontend/packages/shared/src/utils/restore-document-version.test.ts @@ -0,0 +1,112 @@ +import type {HMBlockNode, HMDocument} from '@seed-hypermedia/client/hm-types' +import {describe, expect, it} from 'vitest' +import {buildRestoreVersionChanges, getRestoreVersionGeneration} from './restore-document-version' + +function node(id: string, text: string): HMBlockNode { + return { + block: { + id, + type: 'Paragraph', + text, + attributes: {}, + annotations: [], + } as HMBlockNode['block'], + children: [], + } +} + +function doc(overrides: Partial): HMDocument { + return { + account: 'z1', + path: '/doc', + version: 'v1', + authors: [], + content: [], + metadata: {}, + visibility: 'PUBLIC', + createTime: '', + updateTime: '', + genesis: 'g1', + ...overrides, + } +} + +describe('buildRestoreVersionChanges', () => { + it('deletes current content before inserting every selected-version block', () => { + const changes = buildRestoreVersionChanges( + doc({content: [node('a', 'latest a'), node('b', 'latest b')]}), + doc({content: [node('a', 'old a'), node('c', 'old c')]}), + ) + + expect(changes.map((change) => change.op.case)).toEqual([ + 'deleteBlock', + 'deleteBlock', + 'moveBlock', + 'replaceBlock', + 'moveBlock', + 'replaceBlock', + ]) + expect(changes[0]?.op.case === 'deleteBlock' ? changes[0].op.value : null).toBe('a') + expect(changes[1]?.op.case === 'deleteBlock' ? changes[1].op.value : null).toBe('b') + expect(changes[2]?.op.case === 'moveBlock' ? changes[2].op.value.blockId : null).toBe('a') + expect(changes[3]?.op.case === 'replaceBlock' ? changes[3].op.value.text : null).toBe('old a') + expect(changes[4]?.op.case === 'moveBlock' ? changes[4].op.value.blockId : null).toBe('c') + expect(changes[5]?.op.case === 'replaceBlock' ? changes[5].op.value.text : null).toBe('old c') + }) + + it('restores arbitrary metadata values and removes fields missing from selected version', () => { + const changes = buildRestoreVersionChanges( + doc({ + metadata: { + name: 'Latest', + summary: 'remove me', + showOutline: true, + theme: {headerLayout: 'Center'}, + custom: {count: 2, stale: 'yes'}, + } as any, + }), + doc({ + metadata: { + name: 'Old', + showOutline: false, + custom: {count: 3, label: 'restored'}, + } as any, + }), + ) + + const attrs = changes.map((change) => (change.op.case === 'setAttribute' ? change.op.value : null)) + expect(attrs.map((attr) => attr?.key.join('.'))).toEqual([ + 'name', + 'summary', + 'showOutline', + 'theme.headerLayout', + 'custom.count', + 'custom.stale', + 'custom.label', + ]) + expect(attrs.map((attr) => attr?.value.case)).toEqual([ + 'stringValue', + 'nullValue', + 'boolValue', + 'nullValue', + 'intValue', + 'nullValue', + 'stringValue', + ]) + }) +}) + +describe('getRestoreVersionGeneration', () => { + it('uses the latest document generation instead of creating a new one', () => { + expect( + getRestoreVersionGeneration( + doc({ + generationInfo: { + genesis: 'g1', + generation: 123n, + }, + }), + ), + ).toBe(123n) + }) +}) diff --git a/frontend/packages/shared/src/utils/restore-document-version.ts b/frontend/packages/shared/src/utils/restore-document-version.ts new file mode 100644 index 000000000..e01b546dd --- /dev/null +++ b/frontend/packages/shared/src/utils/restore-document-version.ts @@ -0,0 +1,46 @@ +import {hmBlocksToEditorContent} from '@seed-hypermedia/client/hmblock-to-editorblock' +import {editorBlockToHMBlock} from '@seed-hypermedia/client/editorblock-to-hmblock' +import type {EditorBlock} from '@seed-hypermedia/client/editor-types' +import type {HMDocument} from '@seed-hypermedia/client/hm-types' +import {Block, DocumentChange} from '../client/.generated/documents/v3alpha/documents_pb' +import {createBlocksMap, extractDeletes, getDocAttributeChanges} from './document-changes' + +/** Build document changes that restore selected version content and metadata on top of the latest document. */ +export function buildRestoreVersionChanges(latestDocument: HMDocument, selectedVersion: HMDocument): DocumentChange[] { + const latestBlocksMap = createBlocksMap(latestDocument.content ?? [], '') + const selectedEditorBlocks = hmBlocksToEditorContent(selectedVersion.content ?? [], {childrenType: 'Group'}) + return [ + ...getDocAttributeChanges(selectedVersion.metadata ?? {}, latestDocument.metadata ?? {}), + ...extractDeletes(latestBlocksMap, []), + ...buildInsertBlockChanges(selectedEditorBlocks, ''), + ] +} + +/** Returns the existing generation that the restore publish must stay within. */ +export function getRestoreVersionGeneration(latestDocument: HMDocument): number | bigint { + const generation = latestDocument.generationInfo?.generation + if (generation == null) throw new Error('Could not load the latest document generation') + return generation +} + +function buildInsertBlockChanges(blocks: EditorBlock[], parentId: string): DocumentChange[] { + return blocks.flatMap((block, index) => [ + new DocumentChange({ + op: { + case: 'moveBlock', + value: { + blockId: block.id, + leftSibling: index > 0 ? blocks[index - 1]?.id ?? '' : '', + parent: parentId, + }, + }, + }), + new DocumentChange({ + op: { + case: 'replaceBlock', + value: Block.fromJson(editorBlockToHMBlock(block)), + }, + }), + ...buildInsertBlockChanges(block.children, block.id), + ]) +} diff --git a/frontend/packages/ui/src/__tests__/document-versions-panel.test.ts b/frontend/packages/ui/src/__tests__/document-versions-panel.test.ts new file mode 100644 index 000000000..f2563d4ff --- /dev/null +++ b/frontend/packages/ui/src/__tests__/document-versions-panel.test.ts @@ -0,0 +1,39 @@ +import {hmId} from '@shm/shared/utils/entity-id-url' +import {describe, expect, it} from 'vitest' +import { + createDocumentVersionsPanelRoute, + DOCUMENT_VERSION_EVENT_TYPES, + isDocumentVersionsPanelRoute, +} from '../document-versions-panel' + +describe('document versions panel route helpers', () => { + it('creates the shared versions panel activity route', () => { + const docId = hmId('z-test', {path: ['docs']}) + + expect(createDocumentVersionsPanelRoute(docId)).toEqual({ + key: 'activity', + id: docId, + filterEventType: ['Ref'], + }) + }) + + it('identifies only the versions activity panel route', () => { + const docId = hmId('z-test', {path: ['docs']}) + + expect(isDocumentVersionsPanelRoute(createDocumentVersionsPanelRoute(docId))).toBe(true) + expect(isDocumentVersionsPanelRoute({key: 'activity', id: docId})).toBe(false) + expect(isDocumentVersionsPanelRoute({key: 'activity', id: docId, filterEventType: ['Comment']})).toBe(false) + expect(isDocumentVersionsPanelRoute({key: 'comments', id: docId})).toBe(false) + expect(isDocumentVersionsPanelRoute(null)).toBe(false) + }) + + it('does not expose a mutable shared filter array through route creation', () => { + const docId = hmId('z-test') + const route = createDocumentVersionsPanelRoute(docId) + + route.filterEventType?.push('Comment') + + expect(DOCUMENT_VERSION_EVENT_TYPES).toEqual(['Ref']) + expect(createDocumentVersionsPanelRoute(docId).filterEventType).toEqual(['Ref']) + }) +}) diff --git a/frontend/packages/ui/src/__tests__/feed.test.ts b/frontend/packages/ui/src/__tests__/feed.test.ts index 725a87947..b6b0f9a8e 100644 --- a/frontend/packages/ui/src/__tests__/feed.test.ts +++ b/frontend/packages/ui/src/__tests__/feed.test.ts @@ -1,5 +1,11 @@ import {describe, expect, it} from 'vitest' -import {getDraftVersionInsertIndex, shouldShowDraftVersionEntry} from '../feed' +import { + canShowRestoreVersionButton, + getDraftVersionInsertIndex, + getLatestDocUpdateVersion, + isSelectedDocUpdateVersion, + shouldShowDraftVersionEntry, +} from '../feed' const draft = { docId: { @@ -43,3 +49,45 @@ describe('draft versions feed helpers', () => { expect(getDraftVersionInsertIndex([docUpdate('latest-version')], {...draft, deps: []})).toBe(0) }) }) + +describe('version selection helpers', () => { + it('uses the newest doc-update event as the latest version for a document feed', () => { + expect(getLatestDocUpdateVersion([docUpdate('latest-version'), docUpdate('old-version')])).toBe('latest-version') + }) + + it('selects the explicit route version', () => { + expect(isSelectedDocUpdateVersion('version-1', 'version-1', false, 'version-2')).toBe(true) + expect(isSelectedDocUpdateVersion('version-2', 'version-1', false, 'version-2')).toBe(false) + }) + + it('selects the latest version when the route has no explicit version', () => { + expect(isSelectedDocUpdateVersion('latest-version', null, true, 'latest-version')).toBe(true) + expect(isSelectedDocUpdateVersion('old-version', null, true, 'latest-version')).toBe(false) + }) +}) + +describe('restore version action helpers', () => { + it('allows restore when the provider exposes a selected account and restore action', () => { + expect( + canShowRestoreVersionButton({ + isSingleResource: true, + selectedAccountUid: 'writer', + latestVersion: 'latest-version', + eventVersion: 'old-version', + hasRestoreAction: true, + }), + ).toBe(true) + }) + + it('does not allow restore without a provider-selected account', () => { + expect( + canShowRestoreVersionButton({ + isSingleResource: true, + selectedAccountUid: undefined, + latestVersion: 'latest-version', + eventVersion: 'old-version', + hasRestoreAction: true, + }), + ).toBe(false) + }) +}) diff --git a/frontend/packages/ui/src/__tests__/resource-page-common.test.ts b/frontend/packages/ui/src/__tests__/resource-page-common.test.ts index f7937d236..4d497aa84 100644 --- a/frontend/packages/ui/src/__tests__/resource-page-common.test.ts +++ b/frontend/packages/ui/src/__tests__/resource-page-common.test.ts @@ -1,12 +1,22 @@ import {hmId} from '@shm/shared' import {describe, expect, it} from 'vitest' import { + getDocumentResourceRouteKey, getCommentReplyPanelRoute, hasUnpublishedDraftForResourceState, shouldSuppressMainCommentEditor, shouldUseDraftForRenderedDocument, } from '../resource-page-common' +describe('getDocumentResourceRouteKey', () => { + it('changes when only the document version changes', () => { + const latest = hmId('alice', {path: ['doc'], latest: true}) + const versioned = hmId('alice', {path: ['doc'], version: 'version-1', latest: false}) + + expect(getDocumentResourceRouteKey(latest)).not.toBe(getDocumentResourceRouteKey(versioned)) + }) +}) + describe('shouldSuppressMainCommentEditor', () => { const docId = hmId('alice', {path: ['doc']}) diff --git a/frontend/packages/ui/src/document-versions-panel.tsx b/frontend/packages/ui/src/document-versions-panel.tsx new file mode 100644 index 000000000..093f9a0ee --- /dev/null +++ b/frontend/packages/ui/src/document-versions-panel.tsx @@ -0,0 +1,46 @@ +import type {UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' +import type {DocumentPanelRoute} from '@shm/shared/routes' +import {activityFilterToSlug} from '@shm/shared/utils/entity-id-url' +import {Feed, type DraftVersionEntry} from './feed' + +/** Activity event types that represent document version updates. */ +export const DOCUMENT_VERSION_EVENT_TYPES = ['Ref'] as const + +/** Builds the canonical side-panel route for document versions. */ +export function createDocumentVersionsPanelRoute( + docId: UnpackedHypermediaId, +): Extract { + return { + key: 'activity', + id: docId, + filterEventType: [...DOCUMENT_VERSION_EVENT_TYPES], + } +} + +/** Returns true when a document panel route is the canonical versions panel route. */ +export function isDocumentVersionsPanelRoute(route: DocumentPanelRoute | null | undefined): boolean { + return route?.key === 'activity' && activityFilterToSlug(route.filterEventType) === 'versions' +} + +/** Shared Versions panel UI used by both web and desktop apps. */ +export function DocumentVersionsPanel({ + docId, + targetDomain, + size = 'sm', + draftVersionEntry, +}: { + docId: UnpackedHypermediaId + targetDomain?: string + size?: 'sm' | 'md' + draftVersionEntry?: DraftVersionEntry +}) { + return ( + + ) +} diff --git a/frontend/packages/ui/src/feed.tsx b/frontend/packages/ui/src/feed.tsx index f9743655b..a9dd83951 100644 --- a/frontend/packages/ui/src/feed.tsx +++ b/frontend/packages/ui/src/feed.tsx @@ -1,21 +1,33 @@ -import {useDeleteComment, useHackyAuthorsSubscriptions} from '@shm/shared/comments-service-provider' import {HMBlockNode, UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' +import {useDeleteComment, useHackyAuthorsSubscriptions} from '@shm/shared/comments-service-provider' +import {useDocumentActions} from '@shm/shared/document-actions-context' import {HMListEventsParams, LoadedCommentEvent, LoadedEvent} from '@shm/shared/models/activity-service' +import {type DocumentMachineEvent} from '@shm/shared/models/document-machine' import {useResource, useSelectedAccountId} from '@shm/shared/models/entity' +import {useDocumentSend} from '@shm/shared/models/use-document-machine' +import {useReadOnlyViewer} from '@shm/shared/readonly-viewer-context' import {DocumentRoute, NavRoute} from '@shm/shared/routes' import {useRouteLink, useUniversalAppContext} from '@shm/shared/routing' import {useTx, useTxString} from '@shm/shared/translation' import {useActivityFeed} from '@shm/shared/use-activity-feed' -import {commentIdToHmId, getCommentTargetId, getVersionHeads, hmId} from '@shm/shared/utils/entity-id-url' +import {commentIdToHmId, getCommentTargetId, getVersionHeads, hmId, latestId} from '@shm/shared/utils/entity-id-url' import {useNavRoute} from '@shm/shared/utils/navigation' import merge from 'lodash/merge' -import {CircleAlert, FilePen, Link, Merge, Trash2, X} from 'lucide-react' -import {Fragment, useEffect, useMemo, useRef} from 'react' +import {CircleAlert, FilePen, Link, Merge, RotateCcw, Trash2, X} from 'lucide-react' +import {Fragment, useEffect, useMemo, useRef, useState} from 'react' import {SelectionContent} from './accessories' -import {useReadOnlyViewer} from '@shm/shared/readonly-viewer-context' import {Button} from './button' import {CommentContent, useDeleteCommentDialog} from './comments' -import {useCopyHmLink} from './use-copy-hm-link' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from './components/alert-dialog' import {HMIcon} from './hm-icon' import {ReplyArrow} from './icons' import {AuthorNameLink, DocumentNameLink, InlineDescriptor, Timestamp} from './inline-descriptor' @@ -25,9 +37,8 @@ import {ResourceToken} from './resource-token' import {Separator} from './separator' import {Spinner} from './spinner' import {Tooltip} from './tooltip' +import {useCopyHmLink} from './use-copy-hm-link' import {cn} from './utils' -import {type DocumentMachineEvent} from '@shm/shared/models/document-machine' -import {useDocumentSend} from '@shm/shared/models/use-document-machine' export type DraftVersionEntry = { docId: UnpackedHypermediaId @@ -51,6 +62,39 @@ export function getDraftVersionInsertIndex(events: LoadedEvent[], draft: DraftVe return baseIndex === -1 ? 0 : baseIndex } +/** Returns the newest document update version from an activity feed ordered newest-first. */ +export function getLatestDocUpdateVersion(events: LoadedEvent[]) { + return events.find((event) => event.type === 'doc-update')?.document.version ?? null +} + +export function isSelectedDocUpdateVersion( + eventVersion: string | undefined, + routeVersion: string | null | undefined, + routeLatest: boolean | null | undefined, + latestVersion: string | null | undefined, +) { + if (!eventVersion) return false + if (routeVersion) return eventVersion === routeVersion + return !!routeLatest && !!latestVersion && eventVersion === latestVersion +} + +export function canShowRestoreVersionButton(input: { + isSingleResource?: boolean + selectedAccountUid?: string + latestVersion?: string | null + eventVersion?: string + hasRestoreAction?: boolean +}) { + return !!( + input.isSingleResource && + input.selectedAccountUid && + input.latestVersion && + input.eventVersion && + input.hasRestoreAction && + input.latestVersion !== input.eventVersion + ) +} + export function Feed({ filterResource, filterAuthors, @@ -152,6 +196,7 @@ export function Feed({ const isSingleResource = filterResource && !filterResource.endsWith('*') ? true : false const shouldRenderDraftVersion = shouldShowDraftVersionEntry(filterEventType, draftVersionEntry) const draftInsertIndex = shouldRenderDraftVersion ? getDraftVersionInsertIndex(allEvents, draftVersionEntry) : -1 + const latestDocUpdateVersion = isSingleResource ? getLatestDocUpdateVersion(allEvents) : null if (error) { return ( @@ -199,6 +244,7 @@ export function Feed({ route={route} targetDomain={targetDomain} size={size} + latestDocUpdateVersion={latestDocUpdateVersion} /> @@ -218,6 +264,7 @@ export function Feed({ route={route} targetDomain={targetDomain} size={size} + latestDocUpdateVersion={latestDocUpdateVersion} /> @@ -307,19 +354,28 @@ function EventHeaderContent({ targetDomain, isSingleResource, route, + latestDocUpdateVersion, }: { event: LoadedEvent targetDomain?: string isSingleResource?: boolean route?: NavRoute | null + latestDocUpdateVersion?: string | null }) { const tx = useTxString() const currentRoute = useNavRoute() const currentAccount = useSelectedAccountId() + const documentActions = useDocumentActions() + const latestDocId = event.type === 'doc-update' ? latestId(event.docId) : null + const latestResource = useResource(latestDocId) + const latestDocument = latestResource.data?.type === 'document' ? latestResource.data.document : null + const latestVersion = latestDocUpdateVersion ?? latestDocument?.version const deleteCommentMutation = useDeleteComment() const deleteCommentDialog = useDeleteCommentDialog() const copyHmLink = useCopyHmLink() const {origin: appOrigin, onPushReference} = useUniversalAppContext() + const [restoreDialogOpen, setRestoreDialogOpen] = useState(false) + const [isRestoring, setIsRestoring] = useState(false) if (event.type == 'comment') { const options: MenuItemType[] = [] if (event.comment && currentAccount && currentAccount == event.comment.author) { @@ -431,6 +487,64 @@ function EventHeaderContent({ if (event.type == 'doc-update') { const docUpdateHeadCount = getVersionHeads(event.document.version).length + const canRestore = canShowRestoreVersionButton({ + isSingleResource, + selectedAccountUid: documentActions.selectedAccountUid, + latestVersion, + eventVersion: event.document.version, + hasRestoreAction: !!documentActions.onRestoreDocumentVersion, + }) + const restoreButton = canRestore ? ( + + + + + e.stopPropagation()}> + + Restore this version? + + This will create a new latest version using this version’s content and metadata. Any current draft for + this document will be removed after the restore succeeds. + + + + Cancel + { + e.preventDefault() + e.stopPropagation() + if (!documentActions.onRestoreDocumentVersion) return + setIsRestoring(true) + try { + await documentActions.onRestoreDocumentVersion(event.docId, event.document) + setRestoreDialogOpen(false) + } catch { + // The platform restore action owns user-facing error toasts. + } finally { + setIsRestoring(false) + } + }} + > + {isRestoring ? 'Restoring…' : 'Restore'} + + + + + ) : null + return (
@@ -461,35 +575,38 @@ function EventHeaderContent({ ) : null} - - - +
+ {restoreButton} + + + +
) } @@ -663,12 +780,14 @@ function EventCommentWithReply({ targetDomain, isSingleResource, size, + latestDocUpdateVersion, }: { event: LoadedCommentEvent route: NavRoute | null targetDomain?: string isSingleResource?: boolean size?: 'sm' | 'md' + latestDocUpdateVersion?: string | null }) { const linkProps = useRouteLink(route) const tx = useTx() @@ -738,6 +857,7 @@ function EventCommentWithReply({ event={event} targetDomain={targetDomain} route={route} + latestDocUpdateVersion={latestDocUpdateVersion} />
@@ -897,25 +1017,32 @@ function EventItem({ targetDomain, isSingleResource, size, + latestDocUpdateVersion, }: { event: LoadedEvent route: NavRoute | null targetDomain?: string isSingleResource?: boolean size?: 'sm' | 'md' + latestDocUpdateVersion?: string | null }) { const currentRoute = useNavRoute() const linkProps = useRouteLink(route ? merge({}, currentRoute, route) : currentRoute) + const latestDocId = event.type === 'doc-update' ? latestId(event.docId) : null + const latestResource = useResource(latestDocId) + const latestDocument = latestResource.data?.type === 'document' ? latestResource.data.document : null + const latestVersion = latestDocUpdateVersion ?? latestDocument?.version + const routeId = currentRoute.key === 'document' ? currentRoute.id : null + const isSelectedVersion = + event.type === 'doc-update' && + isSelectedDocUpdateVersion(event.document.version, routeId?.version, routeId?.latest, latestVersion) const tx = useTx() return (
@@ -930,7 +1057,12 @@ function EventItem({ /> ) : null}
- +
{isSingleResource && event.type == 'doc-update' ? null : (
diff --git a/frontend/packages/ui/src/newspaper.tsx b/frontend/packages/ui/src/newspaper.tsx index 1ea52eb06..fded9cb49 100644 --- a/frontend/packages/ui/src/newspaper.tsx +++ b/frontend/packages/ui/src/newspaper.tsx @@ -30,6 +30,7 @@ import { import {HTMLAttributes, useMemo} from 'react' import {Button} from './button' import {copyUrlToClipboardWithFeedback} from './copy-to-clipboard' +import {createDocumentVersionsPanelRoute} from './document-versions-panel' import {DraftBadge} from './draft-badge' import {FacePile} from './face-pile' import {useImageUrl} from './get-file-url' @@ -78,7 +79,7 @@ export function useDocumentCardMenuItems( navigate({ key: 'document', id: docId, - panel: {key: 'activity', id: docId, filterEventType: ['Ref']}, + panel: createDocumentVersionsPanelRoute(docId), } as any) }, }) diff --git a/frontend/packages/ui/src/panel-layout.tsx b/frontend/packages/ui/src/panel-layout.tsx index d9cd2d976..2ab725c0a 100644 --- a/frontend/packages/ui/src/panel-layout.tsx +++ b/frontend/packages/ui/src/panel-layout.tsx @@ -24,6 +24,8 @@ export interface PanelLayoutProps { panelContent: React.ReactNode | null panelKey: PanelSelectionOptions | null onPanelClose: () => void + /** True when the activity panel is being used as the shared versions panel. */ + isVersionsPanel?: boolean /** For activity panel: current filter state */ filterEventType?: string[] /** For activity panel: filter change handler */ @@ -61,6 +63,7 @@ export function PanelLayout({ panelContent, panelKey, onPanelClose, + isVersionsPanel = false, filterEventType, onFilterChange, widthStorage, @@ -94,7 +97,7 @@ export function PanelLayout({ prevPanelKey.current = panelKey }, [panelKey, onPanelWidthChange]) - const title = getPanelTitle(panelKey) + const title = isVersionsPanel ? 'Document Versions' : getPanelTitle(panelKey) return (
@@ -132,7 +135,7 @@ export function PanelLayout({
- {panelKey === 'activity' && onFilterChange && ( + {panelKey === 'activity' && !isVersionsPanel && onFilterChange && ( )}
diff --git a/frontend/packages/ui/src/resource-page-common.tsx b/frontend/packages/ui/src/resource-page-common.tsx index 808209aef..7213427e4 100644 --- a/frontend/packages/ui/src/resource-page-common.tsx +++ b/frontend/packages/ui/src/resource-page-common.tsx @@ -80,6 +80,7 @@ import {DiscussionsPageContent} from './discussions-page' import {DocumentCover} from './document-cover' import {AuthorPayload, BreadcrumbEntry, Breadcrumbs, DocumentHeader} from './document-header' import {DocumentTools} from './document-tools' +import {DocumentVersionsPanel, isDocumentVersionsPanelRoute} from './document-versions-panel' import {Feed, type DraftVersionEntry} from './feed' import {FeedFilters} from './feed-filters' import {useDocumentLayout} from './layout' @@ -117,6 +118,11 @@ function extractPanelRoute(route: NavRoute): DocumentPanelRoute { return params as DocumentPanelRoute } +/** Returns a stable key for the exact document resource being viewed, including version state. */ +export function getDocumentResourceRouteKey(id: UnpackedHypermediaId): string { + return `${id.id}@${id.version ?? ''}@${id.latest ? 'latest' : ''}` +} + export type ActiveView = | 'content' | 'activity' @@ -479,11 +485,12 @@ export function ResourcePage({ // returns below would unmount DocumentBody and destroy the XState actor on each blip. // Reset on route changes so a newly-created local draft never reuses the parent // document while its draft record is resolving. - const lastGoodRouteIdRef = useRef(docId.id) + const documentResourceRouteKey = getDocumentResourceRouteKey(docId) + const lastGoodRouteIdRef = useRef(documentResourceRouteKey) const hasEverLoadedRef = useRef(false) const lastGoodDocumentRef = useRef(null) - if (lastGoodRouteIdRef.current !== docId.id) { - lastGoodRouteIdRef.current = docId.id + if (lastGoodRouteIdRef.current !== documentResourceRouteKey) { + lastGoodRouteIdRef.current = documentResourceRouteKey hasEverLoadedRef.current = false lastGoodDocumentRef.current = null } @@ -2041,6 +2048,7 @@ function DocumentBody({ panelKey={panelKey} panelContent={panelContent} onPanelClose={handlePanelClose} + isVersionsPanel={isDocumentVersionsPanelRoute(panelRoute)} filterEventType={panelRoute?.key === 'activity' ? panelRoute.filterEventType : undefined} onFilterChange={handleFilterChange} > @@ -2263,6 +2271,11 @@ function PanelContentRenderer({ case 'options': return case 'activity': + if (isDocumentVersionsPanelRoute(panelRoute)) { + return ( + + ) + } return ( + + + ) + } return ( {activityFilterToSlug(activityFilterEventType) !== 'citations' && (