diff --git a/specs/001-mvp-scope/spec.md b/specs/001-mvp-scope/spec.md index 2587baf..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. | @@ -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/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() { />
- +