diff --git a/apps/client/src/features/home/components/new-note-button.tsx b/apps/client/src/features/home/components/new-note-button.tsx index 69bcac37..43649bc6 100644 --- a/apps/client/src/features/home/components/new-note-button.tsx +++ b/apps/client/src/features/home/components/new-note-button.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Menu, Text } from "@mantine/core"; +import { Button, Menu, Stack, Text } from "@mantine/core"; import { IconHourglass, IconPlus } from "@tabler/icons-react"; import { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; @@ -21,11 +21,15 @@ function CreateNoteButton({ temporary, label, icon, + color, }: { writableSpaces: ISpace[]; temporary: boolean; label: string; icon: ReactNode; + // Mantine color token; lets the temporary action tint toward the warm + // orange/amber used by the clock marker + banner while "New note" stays neutral. + color: string; }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -54,7 +58,8 @@ function CreateNoteButton({ - + ); } diff --git a/apps/client/src/features/websocket/tree-socket-reducers.test.ts b/apps/client/src/features/websocket/tree-socket-reducers.test.ts index 20abdf95..81c62b56 100644 --- a/apps/client/src/features/websocket/tree-socket-reducers.test.ts +++ b/apps/client/src/features/websocket/tree-socket-reducers.test.ts @@ -323,4 +323,18 @@ describe("applyAddTreeNode", () => { "child", ]); }); + + it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => { + // A note created as temporary broadcasts addTreeNode with the death-timer + // deadline in its payload; the receiver's inserted node must keep it so + // space-tree-row renders the orange clock marker immediately. + const tree = roots(); + const expiresAt = "2026-06-27T21:00:00.000Z"; + const next = applyAddTreeNode(tree, { + parentId: null as unknown as string, + index: 0, + data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }), + }); + expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt); + }); }); diff --git a/apps/server/src/database/listeners/page.listener.ts b/apps/server/src/database/listeners/page.listener.ts index 3a779aa3..b12dc160 100644 --- a/apps/server/src/database/listeners/page.listener.ts +++ b/apps/server/src/database/listeners/page.listener.ts @@ -21,6 +21,41 @@ export interface TreeNodeSnapshot { position: string; spaceId: string; parentPageId: string | null; + // Death-timer deadline carried so the `addTreeNode` broadcast shows the + // temporary-note clock marker immediately on every client (incl. the author, + // whose optimistic insert can lose the race to this broadcast). null/absent => + // permanent. + temporaryExpiresAt?: Date | string | null; +} + +/** + * Single canonical builder for a `TreeNodeSnapshot` from a page-like row. Both + * the `PAGE_CREATED` event enrichment (`page.repo.insertPage`) and the + * `addTreeNode` broadcast (`WsTreeService.broadcastPageCreated`) build this same + * snapshot; routing both through here keeps the optional `temporaryExpiresAt` + * (and the `?? null` normalisation that pins a permanent note to an explicit + * null) from silently drifting between the two literals. + */ +export function toTreeNodeSnapshot(page: { + id: string; + slugId: string; + title: string | null; + icon: string | null; + position: string; + spaceId: string; + parentPageId: string | null; + temporaryExpiresAt?: Date | string | null; +}): TreeNodeSnapshot { + return { + id: page.id, + slugId: page.slugId, + title: page.title, + icon: page.icon, + position: page.position, + spaceId: page.spaceId, + parentPageId: page.parentPageId, + temporaryExpiresAt: page.temporaryExpiresAt ?? null, + }; } export class PageEvent { diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 45cb57ab..a7ac3a5e 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -16,7 +16,10 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventName } from '../../../common/events/event.contants'; -import { TreeUpdateSnapshot } from '../../listeners/page.listener'; +import { + TreeUpdateSnapshot, + toTreeNodeSnapshot, +} from '../../listeners/page.listener'; /** * Optional extras for the PAGE_UPDATED event emitted by updatePage(s). Lets the @@ -200,17 +203,10 @@ export class PageRepo { this.eventEmitter.emit(EventName.PAGE_CREATED, { pageIds: [result.id], workspaceId: result.workspaceId, - pages: [ - { - id: result.id, - slugId: result.slugId, - title: result.title, - icon: result.icon, - position: result.position, - spaceId: result.spaceId, - parentPageId: result.parentPageId, - }, - ], + // Built via the shared snapshot helper so the field copy (and the + // death-timer deadline that shows the sidebar clock marker without a + // reload) can't drift from the `addTreeNode` broadcast literal. + pages: [toTreeNodeSnapshot(result)], }); return result; diff --git a/apps/server/src/ws/ws-tree.service.spec.ts b/apps/server/src/ws/ws-tree.service.spec.ts index 1ee8d10b..8b5e3a2f 100644 --- a/apps/server/src/ws/ws-tree.service.spec.ts +++ b/apps/server/src/ws/ws-tree.service.spec.ts @@ -83,6 +83,27 @@ describe('WsTreeService', () => { ); }); + it('broadcastPageCreated carries temporaryExpiresAt when the page is a temporary note', async () => { + const expiresAt = new Date('2026-07-01T00:00:00.000Z'); + await service.broadcastPageCreated({ ...snapshot, temporaryExpiresAt: expiresAt }); + + const data = + wsService.emitTreeEvent.mock.calls[0][2].payload.data; + // The death-timer deadline reaches receivers so the clock marker renders + // immediately (incl. the author if this broadcast wins the optimistic race). + expect(data.temporaryExpiresAt).toBe(expiresAt); + }); + + it('broadcastPageCreated pins temporaryExpiresAt to null for a permanent page', async () => { + // Fixture omits temporaryExpiresAt; the `?? null` must send an explicit null + // (permanent) rather than undefined, so receivers clear any stale marker. + await service.broadcastPageCreated(snapshot); + + const data = + wsService.emitTreeEvent.mock.calls[0][2].payload.data; + expect(data.temporaryExpiresAt).toBeNull(); + }); + it('broadcastPageDeleted emits deleteTreeNode with the root node only', async () => { await service.broadcastPageDeleted({ ...snapshot, diff --git a/apps/server/src/ws/ws-tree.service.ts b/apps/server/src/ws/ws-tree.service.ts index 223fb25c..65a6b4d9 100644 --- a/apps/server/src/ws/ws-tree.service.ts +++ b/apps/server/src/ws/ws-tree.service.ts @@ -5,6 +5,7 @@ import { PageMovedEvent, TreeNodeSnapshot, TreeUpdateSnapshot, + toTreeNodeSnapshot, } from '../database/listeners/page.listener'; @Injectable() @@ -28,15 +29,16 @@ export class WsTreeService { // Receivers place by `position` among already-loaded siblings, not by // this absolute index (sender's loaded set differs from receivers'). index: 0, + // Built via the shared snapshot helper (same one page.repo uses to fill + // the event), then extended with the tree-only fields the client + // receiver consumes. The helper carries the death-timer deadline + // (normalised to null => permanent) so receivers — and the author, if + // this broadcast wins the race against the optimistic insert — render + // the temporary-note clock marker immediately, without it drifting from + // the event literal. data: { - id: page.id, - slugId: page.slugId, + ...toTreeNodeSnapshot(page), name: page.title ?? '', - title: page.title, - icon: page.icon, - position: page.position, - spaceId: page.spaceId, - parentPageId: page.parentPageId, hasChildren: false, children: [], },