From 4824ad8b0890c79b4ab01e71eea0a9ac03ff6663 Mon Sep 17 00:00:00 2001 From: Andrei Hajdukewycz Date: Wed, 17 Jun 2026 14:52:50 +0000 Subject: [PATCH 1/2] Sum subtree unread onto collapsed folders. (Fixes #42) --- specs/001-mvp-scope/spec.md | 2 +- src/components/FolderNode.vue | 9 +++- src/components/FolderTree.vue | 11 ++++- tests/unit/components/folder-tree.test.ts | 51 +++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/specs/001-mvp-scope/spec.md b/specs/001-mvp-scope/spec.md index 2587baf..11f30dc 100644 --- a/specs/001-mvp-scope/spec.md +++ b/specs/001-mvp-scope/spec.md @@ -66,7 +66,7 @@ capability. | R-2.10 🟧 Planned | The system shall use route-backed browser navigation for app spaces, folders, and opened messages so refresh, direct links, and the browser back and forward buttons preserve the expected mail context. Folder URLs shall use human-readable names where possible, and message URLs shall use stable server identifiers rather than local cache row ids. | | R-2.11 🟩 Done | When an opened message references inline images by `cid:`, the system shall resolve them for display rather than leaving a broken reference: it shall download the referenced message part through the authenticated worker transport and rewrite the `cid:` reference to an inline `data:` URL inside the same sanitization pass (set via the DOM so the value cannot break out of the attribute). Resolution shall apply only to parts of the message being viewed and only to references the body actually uses. Both resolved inline images and author-embedded `data:` images shall be restricted to a raster image allowlist (PNG, JPEG, GIF, WebP, BMP, AVIF, ICO); SVG and non-image `data:` payloads shall be stripped, since DOMPurify cannot inspect bytes inside a `data:` URL. The HTML body shall continue to render inside the sandboxed, script-free iframe of R-2.3. | | R-2.12 🟩 Done | When the app connects or reconnects to the server — including a returning sign-in (R-1.3) that first paints the mailbox window from the previous session's local cache — the system shall reconcile its active mailbox windows against the server before treating the cached list as current, so newly-delivered mail and messages removed elsewhere appear without a manual refresh (R-3.8). The reconciliation shall use JMAP query-state deltas (`Email/queryChanges`, falling back to a full window rebuild when the server reports it cannot calculate changes) for the most-recently-accessed mailbox windows, and shall always include the Inbox window even when the user last viewed other folders, because the Inbox is the folder opened by default on load. The reconciliation shall be resilient to failures of auxiliary startup syncs: an error fetching identities or contacts shall not prevent the mailbox windows from being reconciled. This requirement covers the connect/reconnect moment; R-3.9 covers the steady-state online case while the user is already connected. | -| R-2.13 🟩 Done | In the folder hierarchy (R-2.1), each folder that has subfolders shall present a disclosure control that expands or collapses its child folders, and the hierarchy shall start fully collapsed so only top-level mailboxes are shown until the user expands a branch. Toggling a folder's disclosure control shall not change the selected folder, and selecting a folder shall not change its expand/collapse state. Each folder's expand/collapse state shall be tracked independently, so collapsing an ancestor hides its subtree without discarding a descendant's retained expanded state. | +| R-2.13 🟩 Done | In the folder hierarchy (R-2.1), each folder that has subfolders shall present a disclosure control that expands or collapses its child folders, and the hierarchy shall start fully collapsed so only top-level mailboxes are shown until the user expands a branch. Toggling a folder's disclosure control shall not change the selected folder, and selecting a folder shall not change its expand/collapse state. Each folder's expand/collapse state shall be tracked independently, so collapsing an ancestor hides its subtree without discarding a descendant's retained expanded state. A collapsed folder's unread count shall be the sum of unread messages across the folder and all folders in its hidden subtree; an expanded folder shall show only its own unread count. | ### 3. Triage diff --git a/src/components/FolderNode.vue b/src/components/FolderNode.vue index 94bb5c2..63053b0 100644 --- a/src/components/FolderNode.vue +++ b/src/components/FolderNode.vue @@ -15,10 +15,17 @@ const props = defineProps({ }); const current = computed(() => props.currentFolderId === props.folder.id); -const unread = computed(() => Number(props.folder.unread_emails) || 0); const iconSvg = computed(() => props.folder.icon); const hasChildren = computed(() => (props.folder.children?.length ?? 0) > 0); const collapsed = computed(() => hasChildren.value && props.isCollapsed(props.folder.id)); +// A collapsed parent shows the unread total of its whole subtree; an +// expanded or leaf folder shows only its own unread count (children +// surface their own counts when visible). +const unread = computed(() => ( + collapsed.value + ? Number(props.folder.subtree_unread) || 0 + : Number(props.folder.unread_emails) || 0 +)); const indent = computed(() => `${10 + (props.folder.depth ?? 0) * 16}px`); const style = computed(() => ({ paddingLeft: indent.value, diff --git a/src/components/FolderTree.vue b/src/components/FolderTree.vue index 0ec6640..97e972d 100644 --- a/src/components/FolderTree.vue +++ b/src/components/FolderTree.vue @@ -35,12 +35,21 @@ const tree = computed(() => { function build(folder, depth) { const childrenForFolder = children(folder.id); const presentation = folderPresentation(folder); + const builtChildren = childrenForFolder.map((c) => build(c, depth + 1)); + // Roll the subtree's unread total up to each node so a collapsed + // folder can show the unread count of everything hidden beneath it. + const ownUnread = Number(folder.unread_emails) || 0; + const subtreeUnread = builtChildren.reduce( + (sum, child) => sum + (Number(child.subtree_unread) || 0), + ownUnread, + ); return { ...folder, depth, icon: presentation.icon, tone: presentation.color, - children: childrenForFolder.map((c) => build(c, depth + 1)), + children: builtChildren, + subtree_unread: subtreeUnread, }; } return (byParent.get('ROOT') ?? []).map((f) => build(f, 0)); diff --git a/tests/unit/components/folder-tree.test.ts b/tests/unit/components/folder-tree.test.ts index b5acd79..af1100c 100644 --- a/tests/unit/components/folder-tree.test.ts +++ b/tests/unit/components/folder-tree.test.ts @@ -159,3 +159,54 @@ describe('FolderTree collapse/expand', () => { expect(wrapper.text()).toContain('Gamma'); }); }); + +describe('FolderTree collapsed unread totals', () => { + // Projects(0) > Alpha(2) > Gamma(3), plus Projects > Beta(5). + // Projects subtree unread = 0 + 2 + 3 + 5 = 10. + function seedUnread(mailStore) { + mailStore.folders = [ + makeFolder(1, { name: 'Inbox', role: 'inbox', unread_emails: 1 }), + makeFolder(10, { name: 'Projects', unread_emails: 0 }), + makeFolder(11, { name: 'Alpha', parent_id: 10, unread_emails: 2 }), + makeFolder(12, { name: 'Gamma', parent_id: 11, unread_emails: 3 }), + makeFolder(13, { name: 'Beta', parent_id: 10, unread_emails: 5 }), + makeFolder(20, { name: 'Reports', unread_emails: 0 }), + ]; + } + + it('sums the whole subtree unread on a collapsed parent', async () => { + const mailStore = useMailStore(); + seedUnread(mailStore); + + const wrapper = mount(FolderTree); + await nextTick(); + + expect(nodeByName(wrapper, 'Projects').find('.folder-node__count').text()).toBe('10'); + // A leaf still shows only its own count. + expect(nodeByName(wrapper, 'Inbox').find('.folder-node__count').text()).toBe('1'); + }); + + it('shows only the own count once expanded, and rolls up at each level', async () => { + const mailStore = useMailStore(); + seedUnread(mailStore); + + const wrapper = mount(FolderTree); + await nextTick(); + + await nodeByName(wrapper, 'Projects').find('button.folder-node__toggle').trigger('click'); + await nextTick(); + + // Projects' own unread is 0, so no badge once expanded. + expect(nodeByName(wrapper, 'Projects').find('.folder-node__count').exists()).toBe(false); + // Beta is a leaf: its own 5. Alpha is collapsed: its subtree 2 + 3 = 5. + expect(nodeByName(wrapper, 'Beta').find('.folder-node__count').text()).toBe('5'); + expect(nodeByName(wrapper, 'Alpha').find('.folder-node__count').text()).toBe('5'); + + await nodeByName(wrapper, 'Alpha').find('button.folder-node__toggle').trigger('click'); + await nextTick(); + + // Expanded Alpha now shows only its own 2; Gamma shows its own 3. + expect(nodeByName(wrapper, 'Alpha').find('.folder-node__count').text()).toBe('2'); + expect(nodeByName(wrapper, 'Gamma').find('.folder-node__count').text()).toBe('3'); + }); +}); From 5c887897aefeefbded512445ae9849ff463ef555 Mon Sep 17 00:00:00 2001 From: Andrei Hajdukewycz Date: Wed, 17 Jun 2026 14:53:00 +0000 Subject: [PATCH 2/2] Show an empty body instead of loading forever for empty messages. (Fixes #43) --- specs/001-mvp-scope/spec.md | 2 +- src/components/MessageView.vue | 6 +++- src/composables/useBodyPrefetch.ts | 7 +++- tests/unit/components/message-view.test.ts | 32 +++++++++++++++++++ .../unit/composables/useBodyPrefetch.test.ts | 11 +++++++ 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/specs/001-mvp-scope/spec.md b/specs/001-mvp-scope/spec.md index 11f30dc..296f738 100644 --- a/specs/001-mvp-scope/spec.md +++ b/specs/001-mvp-scope/spec.md @@ -56,7 +56,7 @@ capability. |:--|:--| | R-2.1 🟩 Done | The system shall display the signed-in account's folder hierarchy with role icons and per-folder unread counts. Role-based mailboxes (Inbox, Drafts, Sent, Archive, Junk, Trash) shall render first using their dedicated Thunderbird Desktop icons; all remaining mailboxes shall render below a "Folders" heading and shall use the Thunderbird Desktop generic folder icon in its goldenrod tone rather than a neutral grey. Per-folder unread counts shall display up to 99999 before truncating with a "+" suffix, and the spaces-toolbar unread badge shall show the Inbox unread count up to 9999 before truncating with a "+" suffix. | | R-2.2 🟩 Done | The system shall render a virtualized message list whose scrollbar reflects the full folder size, with placeholder rows for positions not yet fetched. | -| R-2.3 🟩 Done | When the user opens a message, the system shall display its sanitized HTML body in a sandboxed iframe with no script execution, or its plain-text body when HTML is unavailable. | +| R-2.3 🟩 Done | When the user opens a message, the system shall display its sanitized HTML body in a sandboxed iframe with no script execution, or its plain-text body when HTML is unavailable. While the body is still loading the system shall show a loading placeholder, but once the body has loaded with neither HTML nor plain-text content the system shall render an empty body rather than leaving the loading placeholder in place. | | R-2.4 🟩 Done | While the user reads a message, the system shall mark the message as read. | | R-2.5 🟩 Done | The system shall display attachment metadata (name, type, size) on the open message. | | R-2.6 🟩 Done | The system shall persist scroll position per folder so re-entering a folder restores the previous view. | diff --git a/src/components/MessageView.vue b/src/components/MessageView.vue index be5e431..4199d00 100644 --- a/src/components/MessageView.vue +++ b/src/components/MessageView.vue @@ -65,6 +65,10 @@ const bodyColorScheme = computed(() => : effectiveColorScheme.value); const body = computed(() => mailStore.messageBody); +// null while the body is still loading; a (possibly empty) object once the +// load completes. Used to show the loading placeholder only while loading, +// not for a message that has genuinely no body content. +const bodyLoaded = computed(() => body.value != null); const referencedInlineContentIds = computed(() => referencedContentIds(body.value?.html ?? '')); // Render plaintext bodies the way Thunderbird Desktop does: keep the @@ -737,7 +741,7 @@ function closeMessageView() { />
-

Loading message…

+

Loading message…

  • diff --git a/src/composables/useBodyPrefetch.ts b/src/composables/useBodyPrefetch.ts index 8fd37fc..741c287 100644 --- a/src/composables/useBodyPrefetch.ts +++ b/src/composables/useBodyPrefetch.ts @@ -167,7 +167,12 @@ export function useBodyPrefetch(deps: BodyPrefetchDeps) { try { const body = await repo.getMessageBodyForDisplay(accountId, messageId); if (token === bodyFetchToken && deps.isSelected(messageId)) { - messageBody.value = body; + // A message with no body parts and no attachments reads back as + // null. Represent that completed-but-empty load as an empty body + // object so the UI can distinguish "loaded, nothing to show" from + // "still loading" (which stays null) instead of showing a + // perpetual loading placeholder. + messageBody.value = body ?? { text: '', html: '', attachments: [] }; } } catch (err) { console.warn('[body-prefetch] getMessageBodyForDisplay failed', err); diff --git a/tests/unit/components/message-view.test.ts b/tests/unit/components/message-view.test.ts index f72dd64..922cf7c 100644 --- a/tests/unit/components/message-view.test.ts +++ b/tests/unit/components/message-view.test.ts @@ -940,4 +940,36 @@ describe('MessageView HTML body rendering', () => { // alive only if something else references it. expect(document.querySelector('iframe.message-view__html-frame')).toBeNull(); }); + + it('shows the loading placeholder only while the body has not loaded yet', async () => { + // messageBody is null between selecting a message and the body + // load resolving. + await makeSelectedMessage(null); + + const wrapper = mount(MessageView, { attachTo: document.body }); + await nextTick(); + + expect(wrapper.find('.message-view__placeholder').exists()).toBe(true); + expect(wrapper.text()).toContain('Loading message'); + + wrapper.unmount(); + }); + + it('renders an empty body instead of a perpetual loading state when a loaded message has no content', async () => { + // A message with no body parts and no attachments loads as an empty + // body object; the view must drop the loading placeholder and show + // nothing rather than spinning forever. + await makeSelectedMessage({ text: '', html: '', attachments: [] }); + + const wrapper = mount(MessageView, { attachTo: document.body }); + await nextTick(); + + expect(wrapper.find('.message-view__placeholder').exists()).toBe(false); + expect(wrapper.find('iframe.message-view__html-frame').exists()).toBe(false); + expect(wrapper.find('.message-view__text').exists()).toBe(false); + // The article and its body container still render; the body is empty. + expect(wrapper.find('.message-view__body').exists()).toBe(true); + + wrapper.unmount(); + }); }); diff --git a/tests/unit/composables/useBodyPrefetch.test.ts b/tests/unit/composables/useBodyPrefetch.test.ts index 0e9e790..fa41420 100644 --- a/tests/unit/composables/useBodyPrefetch.test.ts +++ b/tests/unit/composables/useBodyPrefetch.test.ts @@ -74,6 +74,17 @@ describe('useBodyPrefetch', () => { expect(prefetch.messageBody.value?.text).toBe('body'); }); + it('represents a missing/empty body as an empty body object once the load completes', async () => { + // A truly empty message reads back as null from the repo. The + // loader must still record a completed (empty) load so the UI can + // tell it apart from the still-loading null state. + repo.getMessageBodyForDisplay = vi.fn(async () => null); + selected = 7; + const token = prefetch.nextDisplayToken(); + await prefetch.loadBodyForDisplay(7, token); + expect(prefetch.messageBody.value).toEqual({ text: '', html: '', attachments: [] }); + }); + it('drops a stale Email/get when selection moved before the response landed', async () => { selected = 7; const stale = prefetch.nextDisplayToken();