Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions specs/001-mvp-scope/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +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. |

### 3. Triage

Expand Down
137 changes: 100 additions & 37 deletions src/components/FolderNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const props = defineProps({
folder: { type: Object, required: true },
currentFolderId: { type: [Number, String, null], default: null },
onPick: { type: Function, required: true },
isCollapsed: { type: Function, required: true },
onToggle: { type: Function, required: true },
dropState: { type: Function, default: null },
onFolderDragEnter: { type: Function, default: null },
onFolderDragOver: { type: Function, default: null },
Expand All @@ -15,6 +17,8 @@ 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));
const indent = computed(() => `${10 + (props.folder.depth ?? 0) * 16}px`);
const style = computed(() => ({
paddingLeft: indent.value,
Expand All @@ -27,11 +31,14 @@ const showIndexProgress = computed(() =>
&& indexPercent.value < 100,
);
const dropStateValue = computed(() => props.dropState?.(props.folder) ?? null);

function toggle() {
props.onToggle(props.folder.id);
}
</script>

<template>
<button
type="button"
<div
class="folder-node"
:class="{
'is-current': current,
Expand All @@ -40,29 +47,52 @@ const dropStateValue = computed(() => props.dropState?.(props.folder) ?? null);
'is-drop-invalid': dropStateValue === 'invalid',
}"
:style="style"
@click="onPick(folder.id)"
@dragenter="onFolderDragEnter?.(folder, $event)"
@dragover="onFolderDragOver?.(folder, $event)"
@dragleave="onFolderDragLeave?.(folder, $event)"
@drop="onFolderDrop?.(folder, $event)"
>
<span class="folder-node__icon" aria-hidden="true" v-html="iconSvg" />
<span class="folder-node__name">{{ folder.name || '(unnamed)' }}</span>
<span v-if="showIndexProgress" class="folder-node__index">{{ indexPercent }}%</span>
<span v-if="unread > 0" class="folder-node__count">{{ unread > 99999 ? '99999+' : unread }}</span>
</button>
<FolderNode
v-for="child in folder.children"
:key="child.id"
:folder="child"
:current-folder-id="currentFolderId"
:on-pick="onPick"
:drop-state="dropState"
:on-folder-drag-enter="onFolderDragEnter"
:on-folder-drag-over="onFolderDragOver"
:on-folder-drag-leave="onFolderDragLeave"
:on-folder-drop="onFolderDrop"
/>
<button
v-if="hasChildren"
type="button"
class="folder-node__toggle"
:class="{ 'is-collapsed': collapsed }"
:aria-expanded="!collapsed"
:aria-label="collapsed ? 'Expand folder' : 'Collapse folder'"
@click="toggle"
>
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
<path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<span v-else class="folder-node__toggle folder-node__toggle--spacer" aria-hidden="true" />
<button
type="button"
class="folder-node__button"
@click="onPick(folder.id)"
>
<span class="folder-node__icon" aria-hidden="true" v-html="iconSvg" />
<span class="folder-node__name">{{ folder.name || '(unnamed)' }}</span>
<span v-if="showIndexProgress" class="folder-node__index">{{ indexPercent }}%</span>
<span v-if="unread > 0" class="folder-node__count">{{ unread > 99999 ? '99999+' : unread }}</span>
</button>
</div>
<template v-if="hasChildren && !collapsed">
<FolderNode
v-for="child in folder.children"
:key="child.id"
:folder="child"
:current-folder-id="currentFolderId"
:on-pick="onPick"
:is-collapsed="isCollapsed"
:on-toggle="onToggle"
:drop-state="dropState"
:on-folder-drag-enter="onFolderDragEnter"
:on-folder-drag-over="onFolderDragOver"
:on-folder-drag-leave="onFolderDragLeave"
:on-folder-drop="onFolderDrop"
/>
</template>
</template>

<script lang="ts">
Expand All @@ -73,28 +103,13 @@ export default { name: 'FolderNode' };
.folder-node {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
background: transparent;
border: 0;
outline: 0;
box-shadow: none;
appearance: none;
-webkit-appearance: none;
gap: 2px;
padding: 0 10px 0 0;
border-radius: 8px;
text-align: left;
cursor: pointer;
font: inherit;
color: var(--text);
width: 100%;
min-width: 0;
}
.folder-node:hover { background: var(--rowHover); }
.folder-node:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
.folder-node.is-current {
background: var(--rowActive);
color: var(--text);
font-weight: 500;
}
.folder-node.is-drop-valid {
background: color-mix(in srgb, var(--accent) 14%, var(--panel));
Expand All @@ -105,6 +120,54 @@ export default { name: 'FolderNode' };
box-shadow: inset 0 0 0 1px color-mix(in srgb, #d93025 55%, transparent);
cursor: not-allowed;
}
.folder-node__toggle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
margin: 7px 0;
padding: 0;
background: transparent;
border: 0;
border-radius: 4px;
color: var(--muted);
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.folder-node__toggle:hover { background: color-mix(in srgb, var(--text) 10%, transparent); }
.folder-node__toggle:focus-visible { box-shadow: 0 0 0 2px var(--accent); outline: 0; }
.folder-node__toggle svg {
transition: transform 0.12s ease;
transform: rotate(90deg);
}
.folder-node__toggle.is-collapsed svg { transform: rotate(0deg); }
.folder-node__toggle--spacer {
cursor: default;
background: transparent;
}
.folder-node__button {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
background: transparent;
border: 0;
outline: 0;
box-shadow: none;
appearance: none;
-webkit-appearance: none;
text-align: left;
cursor: pointer;
font: inherit;
color: var(--text);
flex: 1;
min-width: 0;
}
.folder-node__button:focus-visible { box-shadow: 0 0 0 2px var(--accent); border-radius: 6px; }
.folder-node.is-current .folder-node__button { font-weight: 500; }
.folder-node__icon {
display: block;
flex-shrink: 0;
Expand Down
22 changes: 22 additions & 0 deletions src/components/FolderTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ const tree = computed(() => {
const mainFolders = computed(() => tree.value.filter(isMainFolder));
const userFolders = computed(() => tree.value.filter((f) => !isMainFolder(f)));

// Track explicitly-expanded folders; everything else defaults to
// collapsed, so the tree starts fully closed.
const expandedFolderIds = ref(new Set());

function isFolderCollapsed(id) {
return !expandedFolderIds.value.has(id);
}

function toggleFolderCollapsed(id) {
const next = new Set(expandedFolderIds.value);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
expandedFolderIds.value = next;
}

function pickFolder(id) { mailStore.selectFolder(id); }

function dropStateFor(folder) {
Expand Down Expand Up @@ -101,6 +119,8 @@ async function onFolderDrop(folder, event) {
:folder="folder"
:current-folder-id="mailStore.currentFolderId"
:on-pick="pickFolder"
:is-collapsed="isFolderCollapsed"
:on-toggle="toggleFolderCollapsed"
:drop-state="dropStateFor"
:on-folder-drag-enter="onFolderDragEnter"
:on-folder-drag-over="onFolderDragOver"
Expand All @@ -114,6 +134,8 @@ async function onFolderDrop(folder, event) {
:folder="folder"
:current-folder-id="mailStore.currentFolderId"
:on-pick="pickFolder"
:is-collapsed="isFolderCollapsed"
:on-toggle="toggleFolderCollapsed"
:drop-state="dropStateFor"
:on-folder-drag-enter="onFolderDragEnter"
:on-folder-drag-over="onFolderDragOver"
Expand Down
Loading
Loading