diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 79b5fc67..f066c7fe 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1046,7 +1046,7 @@ export class DemoJMAPClient implements IJMAPClient { const node: FileNode = { id: generateDemoId('file'), parentId, name, type: 'd', blobId: null, size: 0, - created: new Date().toISOString(), updated: new Date().toISOString(), + created: new Date().toISOString(), modified: new Date().toISOString(), }; this.data.fileNodes.push(node); return node; @@ -1056,7 +1056,7 @@ export class DemoJMAPClient implements IJMAPClient { const node: FileNode = { id: generateDemoId('file'), parentId, name, type, blobId, size, - created: new Date().toISOString(), updated: new Date().toISOString(), + created: new Date().toISOString(), modified: new Date().toISOString(), }; this.data.fileNodes.push(node); return node; @@ -1064,7 +1064,7 @@ export class DemoJMAPClient implements IJMAPClient { async updateFileNode(id: string, updates: Partial>): Promise { const node = this.data.fileNodes.find(n => n.id === id); - if (node) Object.assign(node, updates, { updated: new Date().toISOString() }); + if (node) Object.assign(node, updates, { modified: new Date().toISOString() }); } async updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }> { @@ -1072,7 +1072,7 @@ export class DemoJMAPClient implements IJMAPClient { for (const [id, patch] of Object.entries(updates)) { const node = this.data.fileNodes.find(n => n.id === id); if (node) { - Object.assign(node, patch, { updated: new Date().toISOString() }); + Object.assign(node, patch, { modified: new Date().toISOString() }); updated.push(id); } } diff --git a/lib/demo/fixtures/files.ts b/lib/demo/fixtures/files.ts index e4b3734c..59720d15 100644 --- a/lib/demo/fixtures/files.ts +++ b/lib/demo/fixtures/files.ts @@ -12,7 +12,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: null, size: 0, created: demoDate(-30), - updated: demoDate(-2), + modified: demoDate(-2), }, { id: 'demo-file-photos', @@ -22,7 +22,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: null, size: 0, created: demoDate(-30), - updated: demoDate(-5), + modified: demoDate(-5), }, // Documents contents @@ -34,7 +34,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-1', size: 2150, created: demoDate(-7), - updated: demoDate(-2), + modified: demoDate(-2), }, { id: 'demo-file-quarterly-report', @@ -44,7 +44,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-2', size: 148480, created: demoDate(-14), - updated: demoDate(-14), + modified: demoDate(-14), }, { id: 'demo-file-todo', @@ -54,7 +54,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-3', size: 410, created: demoDate(-3), - updated: demoDate(-1), + modified: demoDate(-1), }, // Photos contents @@ -66,7 +66,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-4', size: 1258291, created: demoDate(-10), - updated: demoDate(-10), + modified: demoDate(-10), }, { id: 'demo-file-team-photo', @@ -76,7 +76,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-5', size: 911360, created: demoDate(-21), - updated: demoDate(-21), + modified: demoDate(-21), }, // Root-level file @@ -88,7 +88,7 @@ export function createDemoFileNodes(): FileNode[] { blobId: 'demo-blob-file-6', size: 68608, created: demoDate(-5), - updated: demoDate(-1), + modified: demoDate(-1), }, ]; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d97ab234..cacef5eb 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -5534,7 +5534,7 @@ export class JMAPClient implements IJMAPClient { } private static FILE_NODE_PROPERTIES = [ - "id", "parentId", "name", "type", "blobId", "size", "created", "updated", + "id", "parentId", "name", "type", "blobId", "size", "created", "modified", // Stalwart omits shareWith/myRights from FileNode/get unless requested // explicitly, so the share dialog and indicators can't see existing // shares without naming them here (same as CALENDAR_PROPERTIES). diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index e94dfba6..7ebdbfff 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -835,7 +835,11 @@ export interface FileNode { blobId: string | null; size: number; created: string; - updated: string; + // Last content/metadata change, server-maintained. The property is named + // `modified` in draft-ietf-jmap-filenode and in Stalwart - there is no + // `updated` on a FileNode. Asking for the wrong name silently yields + // undefined, which made the UI show the creation date forever (#700). + modified: string; // JMAP Sharing (RFC 9670). Populated only when the server advertises the // filenode capability and the properties are explicitly requested. A node is // shared-out when `shareWith` has entries; `myRights` describes what the diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index b22a4da1..7bf6151e 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -25,12 +25,12 @@ function makeMockClient(initial: FileNode[] = []) { }, async createFileDirectory(name: string, parentId: string | null) { // A real folder has no content blob (this is how Stalwart marks a container). - const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: now(), updated: now() }; + const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: now(), modified: now() }; nodes.push(node); return { ...node }; }, async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) { - const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: now(), updated: now() }; + const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: now(), modified: now() }; nodes.push(node); return { ...node }; }, @@ -92,15 +92,15 @@ function makeMockClient(initial: FileNode[] = []) { } const dir = (id: string, name: string, parentId: string | null): FileNode => ({ - id, parentId, name, type: 'd', blobId: null, size: 0, created: '', updated: '', + id, parentId, name, type: 'd', blobId: null, size: 0, created: '', modified: '', }); // An old build's "folder": a directory-typed node that is actually a blob-backed // file, so the server won't let anything be parented under it. const marker = (id: string, name: string, parentId: string | null): FileNode => ({ - id, parentId, name, type: 'd', blobId: `b-${id}`, size: 0, created: '', updated: '', + id, parentId, name, type: 'd', blobId: `b-${id}`, size: 0, created: '', modified: '', }); const file = (id: string, name: string, parentId: string | null): FileNode => ({ - id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', updated: '', + id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', modified: '', }); describe('file-store hierarchy (issue #379)', () => { @@ -343,7 +343,7 @@ describe('file-store hierarchy (issue #379)', () => { // containers). The migration must detect this and undo its marker rename. (client as unknown as { createFileDirectory: typeof client.createFileDirectory }).createFileDirectory = async (name: string, parentId: string | null) => - ({ id: 'bad', parentId, name, type: 'd', blobId: 'b-bad', size: 0, created: '', updated: '' }); + ({ id: 'bad', parentId, name, type: 'd', blobId: 'b-bad', size: 0, created: '', modified: '' }); useFileStore.getState().initClient(client); expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(false); @@ -373,3 +373,41 @@ describe('file-store hierarchy (issue #379)', () => { expect(client._nodes()).toHaveLength(0); }); }); + +describe('file-store modification date (issue #700)', () => { + beforeEach(() => { + useFileStore.setState({ + client: null, + currentParentId: null, + currentPath: '/', + pathStack: [{ id: null, name: '' }], + resources: [], + selectedResources: new Set(), + clipboard: null, + lastAction: null, + }); + }); + + it('shows the server-maintained `modified` date, not `created`', async () => { + // Stalwart names the property `modified` (draft-ietf-jmap-filenode); there + // is no `updated`. Asking for the wrong name used to leave lastModified + // pinned to the creation date, so replacing a file looked unmodified. + const client = makeMockClient([ + { ...file('notes', 'Notes.md', null), created: '2025-01-01T00:00:00Z', modified: '2026-07-30T12:00:00Z' }, + ]); + useFileStore.getState().initClient(client); + + await useFileStore.getState().navigate(null); + expect(useFileStore.getState().resources[0].lastModified).toBe('2026-07-30T12:00:00Z'); + }); + + it('falls back to `created` when the server sends no `modified`', async () => { + const client = makeMockClient([ + { ...file('notes', 'Notes.md', null), created: '2025-01-01T00:00:00Z', modified: '' }, + ]); + useFileStore.getState().initClient(client); + + await useFileStore.getState().navigate(null); + expect(useFileStore.getState().resources[0].lastModified).toBe('2025-01-01T00:00:00Z'); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 28c255d7..ddd92314 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -174,7 +174,7 @@ function nodeToResource(node: FileNode): FileResource { isDirectory: isDir, contentType: isDir ? '' : node.type, contentLength: node.size, - lastModified: node.updated || node.created, + lastModified: node.modified || node.created, blobId: node.blobId, parentId: node.parentId, myRights: node.myRights,