@bobfrankston/rmfmail 1.0.470 → 1.0.471

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@
6
6
  import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, abortMessageListRequests, updateFlags, getThreadMessages, moveMessages as apiMoveMessages } from "../lib/api-client.js";
7
7
  import * as state from "../lib/message-state.js";
8
8
  import type { ListMessage } from "../lib/message-state.js";
9
+ import { showMessage as viewerShow, clearViewer as viewerClear } from "./message-viewer.js";
9
10
  import { showContextMenu, type MenuItem } from "./context-menu.js";
10
11
  import { pickFolder } from "./folder-picker.js";
11
12
 
@@ -31,46 +32,62 @@ let touchWasScroll = false;
31
32
  let currentSort = "date";
32
33
  let currentSortDir: "asc" | "desc" = "desc";
33
34
 
34
- /** S56 refactor slice per-row focus/unfocus.
35
+ /** Single source of truth for "which row is focused" in the list.
35
36
  *
36
- * Every rendered message row has an associated Row record tracking the DOM
37
- * element + account/msg pair. `focusRow` runs the atomic transition:
37
+ * Each rendered row is a `MessageRow` instance owning its DOM element,
38
+ * its message envelope, and its event handlers. `focusRow(row)` runs
39
+ * the atomic transition: unfocus the previous row, mark this one
40
+ * `.selected`, drive the viewer with the row's envelope, dispatch
41
+ * `mailx-focus-changed`. There is no "select state" anywhere else; the
42
+ * viewer has no subscriptions. If `focusRow` isn't called, the preview
43
+ * pane doesn't update — drift between the highlighted row and the
44
+ * preview is structurally impossible.
38
45
  *
39
- * 1. unfocus the previously-focused row (clears `.selected` class)
40
- * 2. mark the new row `.selected`
41
- * 3. update shared state.selected + notify viewer
42
- *
43
- * The viewer's `showMessageGeneration` token still cancels stale body
44
- * fetches during the transition — that's the "async cancellation" piece of
45
- * S56. The "atomic DOM transition" piece is now owned here.
46
- *
47
- * Full abort-signal plumbing through getMessage → fetchMessageBody is a
48
- * separate follow-up; for now the gen token does the job. */
49
- let currentFocusedRow: HTMLElement | null = null;
50
-
51
- function focusRow(row: HTMLElement, accountId: string, msg: any): void {
52
- if (currentFocusedRow && currentFocusedRow !== row) {
53
- // Unfocus the previous atomically — clearing .selected AND
54
- // triggering the viewer's stale-fetch cancel (via the bump inside
55
- // the next showMessage() call).
56
- currentFocusedRow.classList.remove("selected");
57
- }
58
- if (!row.classList.contains("selected")) row.classList.add("selected");
59
- currentFocusedRow = row;
60
- state.select(msg);
61
- onMessageSelect(accountId, msg.uid, msg.folderId);
46
+ * When the focused row's data leaves the list (delete, move, search
47
+ * reload, folder switch), the controller hands focus to a survivor
48
+ * via `focusByIdentity` or, if no survivor exists, calls
49
+ * `releaseFocus()` which clears highlight + viewer in the same call. */
50
+ let focusedRow: MessageRow | null = null;
51
+ const rowByKey = new Map<string, MessageRow>();
52
+
53
+ function rowKey(accountId: string, uid: number | string): string {
54
+ return `${accountId}:${uid}`;
55
+ }
56
+
57
+ function focusRow(row: MessageRow): void {
58
+ if (focusedRow && focusedRow !== row) focusedRow.setSelected(false);
59
+ row.setSelected(true);
60
+ focusedRow = row;
61
+ // Drive the viewer with the row's own envelope. Single call site;
62
+ // the viewer paints headers immediately, fetches body in background.
63
+ viewerShow(row.accountId, row.msg.uid, row.msg.folderId, undefined, false, row.msg);
64
+ onMessageSelect(row.accountId, row.msg.uid, row.msg.folderId);
65
+ document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: row.msg }));
62
66
  }
63
67
 
64
- /** Back-compat shim some call sites still use `focusMessage(accountId, msg)`
65
- * without a DOM row (e.g. thread-popup click). Fall back to the previous
66
- * behavior (state + viewer only) so nothing regresses. */
67
- function focusMessage(accountId: string, msg: any): void {
68
- state.select(msg);
69
- onMessageSelect(accountId, msg.uid, msg.folderId);
70
- // Clear the focused-row reference since we don't have a DOM row here
71
- // the next row click will unfocus whatever was selected anyway.
72
- if (currentFocusedRow) currentFocusedRow.classList.remove("selected");
73
- currentFocusedRow = null;
68
+ /** Read the currently-focused message envelope. Used by app-level
69
+ * features (flag toggle, mark unread, status bar) that need to know
70
+ * what's open in the viewer. */
71
+ export function getCurrentFocused(): ListMessage | null {
72
+ return focusedRow ? focusedRow.msg : null;
73
+ }
74
+
75
+ /** Programmatic focus by identity. Used for thread-popup clicks,
76
+ * keyboard nav, post-delete handoff. Returns true if a row was found
77
+ * and focused. */
78
+ function focusByIdentity(accountId: string, uid: number): boolean {
79
+ const row = rowByKey.get(rowKey(accountId, uid));
80
+ if (!row) return false;
81
+ focusRow(row);
82
+ return true;
83
+ }
84
+
85
+ /** Release the focus slot and clear the preview pane in one call. */
86
+ export function releaseFocus(): void {
87
+ if (focusedRow) focusedRow.setSelected(false);
88
+ focusedRow = null;
89
+ viewerClear();
90
+ document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: null }));
74
91
  }
75
92
 
76
93
  /** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
@@ -101,10 +118,14 @@ export function getSelectedMessages(): { accountId: string; uid: number; folderI
101
118
  function clearSelection(): void {
102
119
  const body = document.getElementById("ml-body");
103
120
  if (body) body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
104
- // S56 seam: the focused-row invariant is "the Row whose .selected is
105
- // currently mine". clearSelection wipes all .selected, so the invariant
106
- // would break if we kept a stale pointer.
107
- currentFocusedRow = null;
121
+ // The focused-row invariant is "the Row whose .selected is currently
122
+ // mine". clearSelection wipes all .selected, so the invariant breaks
123
+ // unless we drop the focused-row reference too.
124
+ if (focusedRow) {
125
+ focusedRow = null;
126
+ viewerClear();
127
+ document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: null }));
128
+ }
108
129
  }
109
130
 
110
131
  /** Deterministic sender-avatar color from a seed string (typically the
@@ -243,10 +264,14 @@ export function initMessageList(handler: MessageSelectHandler): void {
243
264
  });
244
265
  }
245
266
 
246
- // Subscribe to state changes react to removeMessages (move/delete)
247
- state.subscribe((change) => {
248
- if (change === "removed") {
249
- syncDomToState();
267
+ // Viewer signals "this row is gone server-side" via mailx-remove-stale.
268
+ // The list owns row lifecycle, so it runs the removal here — filtering
269
+ // state, removing the DOM row, and handing focus to a survivor (or
270
+ // clearing the pane) in one transaction.
271
+ document.addEventListener("mailx-remove-stale", (e: any) => {
272
+ const { accountId, uid } = e.detail || {};
273
+ if (typeof uid === "number" && typeof accountId === "string") {
274
+ removeMessagesAndReconcile([{ accountId, uid }]);
250
275
  }
251
276
  });
252
277
 
@@ -315,42 +340,48 @@ function updateSortIndicators(): void {
315
340
  }
316
341
 
317
342
  /**
318
- * Sync DOM rows to current state after messages are removed.
319
- * Removes DOM rows that are no longer in state, updates selection.
343
+ * Remove the named messages from the list and reconcile DOM + focus.
344
+ *
345
+ * Single-transaction transition: filters the underlying state, deletes
346
+ * the DOM rows, and either re-focuses a surviving row or releases focus
347
+ * (clearing the viewer). Replaces the old subscribe-and-sync model where
348
+ * state.removeMessages broadcast a "removed" event to two independent
349
+ * subscribers — that path could leave the highlight and preview out of
350
+ * sync if the list and viewer noticed the change in different orders.
351
+ *
352
+ * Call this whenever local rows need to disappear (delete, move,
353
+ * server-side stale removal, undo). Pass identities; the function
354
+ * decides what to focus next.
320
355
  */
321
- function syncDomToState(): void {
322
- const body = document.getElementById("ml-body");
323
- if (!body) return;
324
-
325
- // Build set of UIDs still in state
326
- const stateUids = new Set(state.getMessages().map(m => `${m.accountId}:${m.uid}`));
356
+ export function removeMessagesAndReconcile(
357
+ uids: { accountId: string; uid: number }[],
358
+ ): void {
359
+ const focusedIdent = focusedRow
360
+ ? { accountId: focusedRow.accountId, uid: focusedRow.msg.uid }
361
+ : null;
362
+ const outcome = state.removeMessages(uids, focusedIdent);
327
363
 
328
- // Remove rows not in state
329
- for (const row of Array.from(body.querySelectorAll(".ml-row"))) {
330
- const el = row as HTMLElement;
331
- const key = `${el.dataset.accountId}:${el.dataset.uid}`;
332
- if (!stateUids.has(key)) {
333
- el.remove();
364
+ const body = document.getElementById("ml-body");
365
+ if (body) {
366
+ const stateUids = new Set(state.getMessages().map(m => `${m.accountId}:${m.uid}`));
367
+ for (const row of Array.from(body.querySelectorAll(".ml-row"))) {
368
+ const el = row as HTMLElement;
369
+ const key = `${el.dataset.accountId}:${el.dataset.uid}`;
370
+ if (!stateUids.has(key)) el.remove();
334
371
  }
335
- }
336
-
337
- // Update selection to match state
338
- clearSelection();
339
- const sel = state.getSelected();
340
- if (sel) {
341
- const row = body.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`) as HTMLElement
342
- || body.querySelector(`.ml-row[data-uid="${sel.uid}"]`) as HTMLElement;
343
- if (row) {
344
- row.classList.add("selected");
345
- lastClickedRow = row;
346
- // Trigger viewer update
347
- onMessageSelect(sel.accountId, sel.uid, sel.folderId);
372
+ if (state.getMessages().length === 0) {
373
+ body.innerHTML = `<div class="ml-empty">No messages</div>`;
348
374
  }
349
375
  }
350
376
 
351
- // If no messages left, show empty
352
- if (state.getMessages().length === 0) {
353
- body.innerHTML = `<div class="ml-empty">No messages</div>`;
377
+ if (outcome.focusedWasRemoved) {
378
+ if (outcome.nextSurvivor && focusByIdentity(outcome.nextSurvivor.accountId, outcome.nextSurvivor.uid)) {
379
+ // focusByIdentity handled the transition (DOM class + viewer).
380
+ } else {
381
+ // No survivor (or its row didn't render) — release focus and
382
+ // clear the pane so there's no orphaned preview.
383
+ releaseFocus();
384
+ }
354
385
  }
355
386
  }
356
387
 
@@ -430,11 +461,11 @@ export async function loadSearchResults(query: string, scope = "all", accountId
430
461
  const body = document.getElementById("ml-body");
431
462
  if (!body) return;
432
463
 
433
- // Clear the preview pane old preview from the prior selection lingers
434
- // until the user clicks a search result. Atomic clear-on-list-mutation
435
- // is what Slice D's row-objects-own-preview will do generally; this is
436
- // the band-aid for the search-specific case until that lands.
437
- document.dispatchEvent(new CustomEvent("mailx-clear-viewer"));
464
+ // Search reload tears down the current row set focus must release
465
+ // along with the rows. releaseFocus clears the preview pane in the
466
+ // same call frame, so no orphan preview lingers behind a list that
467
+ // no longer contains the previously-shown row.
468
+ releaseFocus();
438
469
 
439
470
  body.innerHTML = `<div class="ml-empty">Searching...</div>`;
440
471
 
@@ -626,7 +657,26 @@ export async function showThreadPopup(pillEl: HTMLElement, headMsg: any): Promis
626
657
  item.appendChild(date);
627
658
  item.appendChild(subject);
628
659
  item.addEventListener("click", async () => {
629
- focusMessage(msg.accountId, { accountId: msg.accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject, from: msg.from, to: msg.to, cc: msg.cc, date: msg.date, flags: msg.flags, size: msg.size, preview: msg.preview, hasAttachments: msg.hasAttachments });
660
+ // Thread popup viewer-only update. The list keeps showing the
661
+ // thread head highlighted (it's the row in the actual list);
662
+ // the viewer pivots to the clicked thread member. Single path
663
+ // in: the viewer's show() call. No state, no event, no drift.
664
+ const envelope = {
665
+ accountId: msg.accountId,
666
+ uid: msg.uid,
667
+ folderId: msg.folderId,
668
+ subject: msg.subject,
669
+ from: msg.from,
670
+ to: msg.to,
671
+ cc: msg.cc,
672
+ date: msg.date,
673
+ flags: msg.flags,
674
+ size: msg.size,
675
+ preview: msg.preview,
676
+ hasAttachments: msg.hasAttachments,
677
+ };
678
+ viewerShow(msg.accountId, msg.uid, msg.folderId, undefined, false, envelope as ListMessage);
679
+ onMessageSelect(msg.accountId, msg.uid, msg.folderId);
630
680
  popup.remove();
631
681
  });
632
682
  popup.appendChild(item);
@@ -647,57 +697,52 @@ export async function showThreadPopup(pillEl: HTMLElement, headMsg: any): Promis
647
697
  }, 0);
648
698
  }
649
699
 
650
- function appendMessages(body: HTMLElement, accountId: string, items: any[]): void {
651
- // Thread grouping: when the list has the "threaded" class, collapse messages
652
- // sharing the same threadId to a single row showing the most recent message,
653
- // with a small pill indicating the thread size. Pre-threading messages have
654
- // no threadId those are treated as singletons keyed by their own uid.
655
- const threaded = body.classList.contains("threaded");
656
- let rowsToRender: any[] = items;
657
- let threadSize: Map<any, number> | null = null;
658
- if (threaded) {
659
- const threadMap = new Map<string, any>(); // threadId → newest msg
660
- threadSize = new Map<any, number>();
661
- for (const msg of items) {
662
- const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
663
- const existing = threadMap.get(key);
664
- if (!existing || (msg.date || 0) > (existing.date || 0)) {
665
- threadMap.set(key, msg);
666
- }
667
- }
668
- // Count messages per thread
669
- for (const msg of items) {
670
- const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
671
- const head = threadMap.get(key);
672
- if (head) threadSize.set(head, (threadSize.get(head) || 0) + 1);
673
- }
674
- rowsToRender = Array.from(threadMap.values()).sort((a, b) => (b.date || 0) - (a.date || 0));
675
- }
676
- for (const msg of rowsToRender) {
677
- const msgAccountId = msg.accountId || accountId;
700
+ /** A rendered row in the message list.
701
+ *
702
+ * Owns its DOM element, the message envelope it represents, and all of
703
+ * its event handlers (click, dblclick, drag, contextmenu, touch
704
+ * long-press, plus the avatar / flag / thread-pill child handlers).
705
+ * State changes that affect appearance (selected, unread, flagged,
706
+ * body-cached) go through methods so the OO layer is the single point
707
+ * of mutation no `el.classList.toggle("selected")` scattered through
708
+ * the codebase.
709
+ *
710
+ * Lifecycle: constructor builds DOM and wires handlers; `attach(body)`
711
+ * inserts into the list; `detach()` removes from DOM and from the
712
+ * module-level rowByKey map. The list controller diff-updates rows on
713
+ * list mutations.
714
+ *
715
+ * Multi-select still uses `.selected` class queries — that's a single
716
+ * DOM-level concept where drift isn't an issue (no separate render
717
+ * surface to drift against). The Row class wraps the *focus* concept
718
+ * (which couples to the viewer) as a fate-shared unit. */
719
+ class MessageRow {
720
+ el: HTMLElement;
721
+ private flagEl: HTMLSpanElement;
722
+
723
+ constructor(
724
+ public msg: any,
725
+ public accountId: string,
726
+ threadHead: boolean,
727
+ threadCount: number,
728
+ showAccountTag: boolean,
729
+ ) {
678
730
  const row = document.createElement("div");
731
+ this.el = row;
679
732
  row.className = "ml-row";
680
733
  row.draggable = true;
681
734
  if (!msg.flags.includes("\\Seen")) row.classList.add("unread");
682
735
  if (msg.flags.includes("\\Flagged")) row.classList.add("flagged");
683
736
  if (!msg.bodyPath) row.classList.add("not-downloaded");
684
- // Pink-row visible reconciliation state (S1 slice C): a queued local
685
- // action (move/flag/delete) hasn't been ACK'd by the server yet.
686
737
  if (msg.pending) row.classList.add("pending-reconcile");
687
- // Reply-row marker: messages with In-Reply-To are replies. Shows a
688
- // subtle left-border accent so the eye can pick out threaded replies
689
- // without enabling full thread grouping.
690
738
  if (msg.inReplyTo) row.classList.add("is-reply");
691
739
  row.dataset.uid = String(msg.uid);
692
- row.dataset.accountId = msgAccountId;
740
+ row.dataset.accountId = accountId;
693
741
  row.dataset.folderId = String(msg.folderId);
694
742
  if (msg.threadId) row.dataset.threadId = msg.threadId;
743
+ if (threadHead) row.classList.add("thread-head");
695
744
 
696
- // Sender avatar \u2014 Thunderbird-style colored circle with the first
697
- // initial of the sender's display name. Doubles as the multi-select
698
- // affordance: in `multi-select-on` mode, the avatar swaps to a
699
- // checkmark via CSS. Color is derived deterministically from the
700
- // address so the same sender keeps the same color across rows.
745
+ // ── Avatar (sender circle, doubles as multi-select affordance) ──
701
746
  const fromName = (showToInsteadOfFrom && msg.to?.length)
702
747
  ? (msg.to[0].name || msg.to[0].address || "?")
703
748
  : (msg.from?.name || msg.from?.address || "?");
@@ -708,78 +753,18 @@ function appendMessages(body: HTMLElement, accountId: string, items: any[]): voi
708
753
  avatar.textContent = initial;
709
754
  avatar.style.background = senderColor(seedAddr);
710
755
  avatar.title = msg.from?.address || "";
711
- // Tapping the avatar enters multi-select mode (or toggles in it,
712
- // mirroring Thunderbird/Gmail). Click bubbles to the row otherwise,
713
- // which would open the message — stopPropagation here keeps the
714
- // avatar a dedicated selection affordance.
715
- avatar.addEventListener("click", (e) => {
716
- e.stopPropagation();
717
- const body = document.getElementById("ml-body");
718
- if (!body) return;
719
- if (body.classList.contains("multi-select-on")) {
720
- row.classList.toggle("selected");
721
- } else {
722
- clearSelection();
723
- row.classList.add("selected");
724
- body.classList.add("multi-select-on");
725
- }
726
- lastClickedRow = row;
727
- updateBulkBar();
728
- });
729
-
730
- // Right-click (or long-press) on the avatar → bulk-selection menu.
731
- // Putting it on the avatar is contextually right: the avatar is the
732
- // "select" affordance, so its menu owns operations on the selection
733
- // set. "Select all visible" is the load-bearing item — there's no
734
- // Ctrl-A equivalent on touch and the scope-after-search use case
735
- // demands it.
736
- avatar.addEventListener("contextmenu", async (e) => {
737
- e.preventDefault();
738
- e.stopPropagation();
739
- const { showContextMenu } = await import("./context-menu.js");
740
- const body = document.getElementById("ml-body");
741
- const visibleRows = body
742
- ? Array.from(body.querySelectorAll<HTMLElement>(".ml-row:not(.filter-hidden)"))
743
- : [];
744
- const selectedCount = body
745
- ? body.querySelectorAll(".ml-row.selected").length
746
- : 0;
747
- showContextMenu(e.clientX, e.clientY, [
748
- {
749
- label: `Select all (${visibleRows.length})`,
750
- action: () => {
751
- if (!body) return;
752
- body.classList.add("multi-select-on");
753
- for (const r of visibleRows) r.classList.add("selected");
754
- lastClickedRow = visibleRows[visibleRows.length - 1] || null;
755
- updateBulkBar();
756
- },
757
- disabled: visibleRows.length === 0,
758
- },
759
- {
760
- label: `Clear selection${selectedCount ? ` (${selectedCount})` : ""}`,
761
- action: () => exitMultiSelect(),
762
- disabled: selectedCount === 0,
763
- },
764
- {
765
- label: "Invert selection",
766
- action: () => {
767
- if (!body) return;
768
- body.classList.add("multi-select-on");
769
- for (const r of visibleRows) r.classList.toggle("selected");
770
- lastClickedRow = visibleRows[visibleRows.length - 1] || null;
771
- updateBulkBar();
772
- },
773
- disabled: visibleRows.length === 0,
774
- },
775
- ]);
776
- });
756
+ avatar.addEventListener("click", (e) => this.onAvatarClick(e));
757
+ avatar.addEventListener("contextmenu", (e) => this.onAvatarContextMenu(e));
777
758
 
759
+ // ── Flag star (toggle on click) ──
778
760
  const flag = document.createElement("span");
779
761
  flag.className = "ml-flag";
780
- flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
762
+ flag.textContent = msg.flags.includes("\\Flagged") ? "" : "";
781
763
  flag.title = "Toggle flag";
764
+ flag.addEventListener("click", (e) => this.onFlagClick(e));
765
+ this.flagEl = flag;
782
766
 
767
+ // ── From column ──
783
768
  const from = document.createElement("span");
784
769
  from.className = "ml-from";
785
770
  if (showToInsteadOfFrom && msg.to?.length) {
@@ -787,15 +772,13 @@ function appendMessages(body: HTMLElement, accountId: string, items: any[]): voi
787
772
  } else {
788
773
  from.textContent = msg.from.name || msg.from.address;
789
774
  }
790
- if (!accountId && msgAccountId) {
775
+ if (showAccountTag && accountId) {
791
776
  const tag = document.createElement("span");
792
777
  tag.className = "ml-account-tag";
793
- tag.textContent = msgAccountId.charAt(0).toUpperCase();
794
- tag.title = msgAccountId;
778
+ tag.textContent = accountId.charAt(0).toUpperCase();
779
+ tag.title = accountId;
795
780
  from.prepend(tag);
796
781
  }
797
- // Search/cross-folder results carry folderName — show a tag so the user
798
- // can tell which folder each hit lives in.
799
782
  if (msg.folderName) {
800
783
  const folderTag = document.createElement("span");
801
784
  folderTag.className = "ml-folder-tag";
@@ -803,8 +786,6 @@ function appendMessages(body: HTMLElement, accountId: string, items: any[]): voi
803
786
  folderTag.title = `In folder: ${msg.folderName}`;
804
787
  from.prepend(folderTag);
805
788
  }
806
- // Unified inbox: same Message-ID exists under >=2 accounts → ⇆ badge.
807
- // Tooltip names the count so the user knows "this appears on N".
808
789
  if ((msg.dupeCount as number) >= 2) {
809
790
  const dupe = document.createElement("span");
810
791
  dupe.className = "ml-dupe-tag";
@@ -813,170 +794,229 @@ function appendMessages(body: HTMLElement, accountId: string, items: any[]): voi
813
794
  from.prepend(dupe);
814
795
  }
815
796
 
797
+ // ── Subject (with optional thread pill + preview snippet) ──
816
798
  const subject = document.createElement("span");
817
799
  subject.className = "ml-subject";
818
800
  subject.innerHTML = escapeHtml(msg.subject);
819
- // Thread size pill: click to show a popup list of the thread's messages.
820
- if (threadSize) {
821
- const n = threadSize.get(msg) || 1;
822
- if (n > 1 && msg.threadId) {
823
- row.classList.add("thread-head");
824
- row.dataset.threadId = msg.threadId;
825
- const threadPill = document.createElement("span");
826
- threadPill.className = "ml-thread-pill";
827
- threadPill.textContent = String(n);
828
- threadPill.title = `${n} messages in this thread — click to see list`;
829
- threadPill.addEventListener("click", async (e) => {
830
- e.stopPropagation();
831
- await showThreadPopup(threadPill, msg);
832
- });
833
- subject.prepend(threadPill);
834
- }
801
+ if (threadHead && threadCount > 1 && msg.threadId) {
802
+ const threadPill = document.createElement("span");
803
+ threadPill.className = "ml-thread-pill";
804
+ threadPill.textContent = String(threadCount);
805
+ threadPill.title = `${threadCount} messages in this thread — click to see list`;
806
+ threadPill.addEventListener("click", async (e) => {
807
+ e.stopPropagation();
808
+ await showThreadPopup(threadPill, msg);
809
+ });
810
+ subject.prepend(threadPill);
835
811
  }
836
812
  if (msg.preview) {
837
813
  const preview = document.createElement("span");
838
814
  preview.className = "ml-preview";
839
- preview.textContent = ` \u2014 ${msg.preview}`;
815
+ preview.textContent = ` ${msg.preview}`;
840
816
  subject.appendChild(preview);
841
817
  }
842
818
 
819
+ // ── Date column ──
843
820
  const date = document.createElement("span");
844
821
  date.className = "ml-date";
845
822
  date.textContent = formatDate(msg.date);
846
823
 
847
- flag.addEventListener("click", async (e) => {
848
- e.stopPropagation();
849
- const isFlagged = row.classList.contains("flagged");
850
- const currentFlags: string[] = msg.flags || [];
851
- const newFlags = isFlagged
852
- ? currentFlags.filter((f: string) => f !== "\\Flagged")
853
- : [...currentFlags, "\\Flagged"];
854
- try {
855
- await updateFlags(msgAccountId, msg.uid, newFlags);
856
- msg.flags = newFlags;
857
- row.classList.toggle("flagged");
858
- flag.textContent = row.classList.contains("flagged") ? "\u2605" : "\u2606";
859
- } catch { /* ignore */ }
860
- });
861
-
862
824
  row.appendChild(avatar);
863
825
  row.appendChild(flag);
864
826
  row.appendChild(from);
865
827
  row.appendChild(date);
866
828
  row.appendChild(subject);
867
829
 
868
- row.addEventListener("click", (e) => {
869
- if (touchWasScroll) { touchWasScroll = false; return; }
870
- // Multi-select mode (entered via long-press on touch): taps toggle
871
- // rows instead of opening messages. Exit mode when the user taps
872
- // outside any row or presses Escape (handled at the body level).
873
- const body = row.parentElement as HTMLElement | null;
874
- if (body?.classList.contains("multi-select-on")) {
875
- row.classList.toggle("selected");
876
- lastClickedRow = row;
877
- updateBulkBar();
878
- return;
879
- }
880
- if (e.shiftKey) {
881
- const anchor = resolveShiftAnchor();
882
- if (anchor) {
883
- clearSelection();
884
- selectRange(anchor, row);
885
- lastClickedRow = row;
886
- row.classList.remove("unread");
887
- focusMessage(msgAccountId, msg);
888
- } else {
889
- // No anchor available (first click of the session, or list
890
- // was just rebuilt with no selection). Treat as a plain
891
- // click so the user gets visible feedback rather than a
892
- // silent no-op.
893
- clearSelection();
894
- focusRow(row, msgAccountId, msg);
895
- lastClickedRow = row;
896
- row.classList.remove("unread");
897
- }
898
- } else if (e.ctrlKey || e.metaKey) {
899
- row.classList.toggle("selected");
900
- lastClickedRow = row;
830
+ row.addEventListener("click", (e) => this.onRowClick(e));
831
+ row.addEventListener("dblclick", (e) => this.onRowDoubleClick(e));
832
+ row.addEventListener("dragstart", (e) => this.onDragStart(e));
833
+ row.addEventListener("dragend", () => row.classList.remove("dragging"));
834
+ row.addEventListener("contextmenu", (e) => this.onRowContextMenu(e));
835
+ this.wireLongPress(row);
836
+ }
837
+
838
+ /** Insert this row into a list body and register it in the lookup map. */
839
+ attach(body: HTMLElement): void {
840
+ body.appendChild(this.el);
841
+ rowByKey.set(rowKey(this.accountId, this.msg.uid), this);
842
+ }
843
+
844
+ /** Remove this row from the DOM and the lookup map. If it was the
845
+ * focused row, the controller is responsible for releasing focus
846
+ * (this method doesn't auto-clear the viewer because the controller
847
+ * may want to hand focus to a sibling instead). */
848
+ detach(): void {
849
+ this.el.remove();
850
+ rowByKey.delete(rowKey(this.accountId, this.msg.uid));
851
+ }
852
+
853
+ setSelected(yes: boolean): void { this.el.classList.toggle("selected", yes); }
854
+ get isSelected(): boolean { return this.el.classList.contains("selected"); }
855
+ setUnreadClass(yes: boolean): void { this.el.classList.toggle("unread", yes); }
856
+ setFlaggedClass(yes: boolean): void {
857
+ this.el.classList.toggle("flagged", yes);
858
+ this.flagEl.textContent = yes ? "" : "☆";
859
+ }
860
+ markBodyCached(): void { this.el.classList.remove("not-downloaded"); }
861
+
862
+ private onAvatarClick(e: MouseEvent): void {
863
+ e.stopPropagation();
864
+ const body = document.getElementById("ml-body");
865
+ if (!body) return;
866
+ if (body.classList.contains("multi-select-on")) {
867
+ this.setSelected(!this.isSelected);
868
+ } else {
869
+ clearSelection();
870
+ this.setSelected(true);
871
+ body.classList.add("multi-select-on");
872
+ }
873
+ lastClickedRow = this.el;
874
+ updateBulkBar();
875
+ }
876
+
877
+ private async onAvatarContextMenu(e: MouseEvent): Promise<void> {
878
+ e.preventDefault();
879
+ e.stopPropagation();
880
+ const { showContextMenu: showMenu } = await import("./context-menu.js");
881
+ const body = document.getElementById("ml-body");
882
+ const visibleRows = body
883
+ ? Array.from(body.querySelectorAll<HTMLElement>(".ml-row:not(.filter-hidden)"))
884
+ : [];
885
+ const selectedCount = body
886
+ ? body.querySelectorAll(".ml-row.selected").length
887
+ : 0;
888
+ showMenu(e.clientX, e.clientY, [
889
+ {
890
+ label: `Select all (${visibleRows.length})`,
891
+ action: () => {
892
+ if (!body) return;
893
+ body.classList.add("multi-select-on");
894
+ for (const r of visibleRows) r.classList.add("selected");
895
+ lastClickedRow = visibleRows[visibleRows.length - 1] || null;
896
+ updateBulkBar();
897
+ },
898
+ disabled: visibleRows.length === 0,
899
+ },
900
+ {
901
+ label: `Clear selection${selectedCount ? ` (${selectedCount})` : ""}`,
902
+ action: () => exitMultiSelect(),
903
+ disabled: selectedCount === 0,
904
+ },
905
+ {
906
+ label: "Invert selection",
907
+ action: () => {
908
+ if (!body) return;
909
+ body.classList.add("multi-select-on");
910
+ for (const r of visibleRows) r.classList.toggle("selected");
911
+ lastClickedRow = visibleRows[visibleRows.length - 1] || null;
912
+ updateBulkBar();
913
+ },
914
+ disabled: visibleRows.length === 0,
915
+ },
916
+ ]);
917
+ }
918
+
919
+ private async onFlagClick(e: MouseEvent): Promise<void> {
920
+ e.stopPropagation();
921
+ const isFlagged = this.el.classList.contains("flagged");
922
+ const currentFlags: string[] = this.msg.flags || [];
923
+ const newFlags = isFlagged
924
+ ? currentFlags.filter((f: string) => f !== "\\Flagged")
925
+ : [...currentFlags, "\\Flagged"];
926
+ try {
927
+ await updateFlags(this.accountId, this.msg.uid, newFlags);
928
+ this.msg.flags = newFlags;
929
+ this.setFlaggedClass(!isFlagged);
930
+ } catch { /* ignore */ }
931
+ }
932
+
933
+ private onRowClick(e: MouseEvent): void {
934
+ if (touchWasScroll) { touchWasScroll = false; return; }
935
+ const body = this.el.parentElement as HTMLElement | null;
936
+ if (body?.classList.contains("multi-select-on")) {
937
+ this.setSelected(!this.isSelected);
938
+ lastClickedRow = this.el;
939
+ updateBulkBar();
940
+ return;
941
+ }
942
+ if (e.shiftKey) {
943
+ const anchor = resolveShiftAnchor();
944
+ if (anchor) {
945
+ clearSelection();
946
+ selectRange(anchor, this.el);
947
+ lastClickedRow = this.el;
948
+ this.setUnreadClass(false);
949
+ focusRow(this);
901
950
  } else {
902
- // Atomic unfocus-previous + focus-this.
903
951
  clearSelection();
904
- focusRow(row, msgAccountId, msg);
905
- lastClickedRow = row;
906
- row.classList.remove("unread");
952
+ focusRow(this);
953
+ lastClickedRow = this.el;
954
+ this.setUnreadClass(false);
907
955
  }
908
- updateBulkBar();
909
- });
956
+ } else if (e.ctrlKey || e.metaKey) {
957
+ this.setSelected(!this.isSelected);
958
+ lastClickedRow = this.el;
959
+ } else {
960
+ clearSelection();
961
+ focusRow(this);
962
+ lastClickedRow = this.el;
963
+ this.setUnreadClass(false);
964
+ }
965
+ updateBulkBar();
966
+ }
910
967
 
911
- // Q64: double-click pop out the message in a floating overlay so
912
- // the user can read it without losing the selected list context.
913
- row.addEventListener("dblclick", (e) => {
914
- e.preventDefault();
915
- e.stopPropagation();
916
- document.dispatchEvent(new CustomEvent("mailx-popout-message", {
917
- detail: { accountId: msgAccountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject }
918
- }));
919
- });
968
+ private onRowDoubleClick(e: MouseEvent): void {
969
+ e.preventDefault();
970
+ e.stopPropagation();
971
+ document.dispatchEvent(new CustomEvent("mailx-popout-message", {
972
+ detail: { accountId: this.accountId, uid: this.msg.uid, folderId: this.msg.folderId, subject: this.msg.subject },
973
+ }));
974
+ }
920
975
 
921
- row.addEventListener("dragstart", (e) => {
922
- if (!row.classList.contains("selected")) {
923
- clearSelection();
924
- row.classList.add("selected");
925
- lastClickedRow = row;
926
- }
927
- const selected = getSelectedMessages();
928
- e.dataTransfer!.setData("application/x-mailx-messages", JSON.stringify(selected));
929
- e.dataTransfer!.setData("application/x-mailx-message", JSON.stringify({
930
- accountId: msgAccountId,
931
- uid: msg.uid,
932
- folderId: msg.folderId,
933
- subject: msg.subject,
934
- }));
935
- e.dataTransfer!.effectAllowed = "copyMove";
936
- row.classList.add("dragging");
937
- if (selected.length > 1) {
938
- const badge = document.createElement("div");
939
- badge.textContent = `${selected.length} messages`;
940
- badge.style.cssText = "position:absolute;top:-1000px;background:#333;color:white;padding:4px 8px;border-radius:4px;font-size:12px";
941
- document.body.appendChild(badge);
942
- e.dataTransfer!.setDragImage(badge, 0, 0);
943
- setTimeout(() => badge.remove(), 0);
944
- }
945
- });
946
- row.addEventListener("dragend", () => row.classList.remove("dragging"));
976
+ private onDragStart(e: DragEvent): void {
977
+ if (!this.isSelected) {
978
+ clearSelection();
979
+ this.setSelected(true);
980
+ lastClickedRow = this.el;
981
+ }
982
+ const selected = getSelectedMessages();
983
+ e.dataTransfer!.setData("application/x-mailx-messages", JSON.stringify(selected));
984
+ e.dataTransfer!.setData("application/x-mailx-message", JSON.stringify({
985
+ accountId: this.accountId,
986
+ uid: this.msg.uid,
987
+ folderId: this.msg.folderId,
988
+ subject: this.msg.subject,
989
+ }));
990
+ e.dataTransfer!.effectAllowed = "copyMove";
991
+ this.el.classList.add("dragging");
992
+ if (selected.length > 1) {
993
+ const badge = document.createElement("div");
994
+ badge.textContent = `${selected.length} messages`;
995
+ badge.style.cssText = "position:absolute;top:-1000px;background:#333;color:white;padding:4px 8px;border-radius:4px;font-size:12px";
996
+ document.body.appendChild(badge);
997
+ e.dataTransfer!.setDragImage(badge, 0, 0);
998
+ setTimeout(() => badge.remove(), 0);
999
+ }
1000
+ }
947
1001
 
948
- // ── Q66: long-press on touch → context menu ──
949
- // Mirrors right-click on the phone where right-click isn't a thing.
950
- // Cancelled by any touchmove or touchend before the threshold.
1002
+ private wireLongPress(row: HTMLElement): void {
951
1003
  let longPressTimer: ReturnType<typeof setTimeout> | null = null;
952
1004
  const LONG_PRESS_MS = 550;
953
- row.addEventListener("touchstart", (e: TouchEvent) => {
954
- const t = e.touches[0];
955
- if (!t) return;
956
- const cx = t.clientX, cy = t.clientY;
1005
+ row.addEventListener("touchstart", (_e: TouchEvent) => {
957
1006
  if (longPressTimer) clearTimeout(longPressTimer);
958
1007
  longPressTimer = setTimeout(() => {
959
1008
  longPressTimer = null;
960
- // Long-press semantics:
961
- // - If the list is already in multi-select mode, toggle this
962
- // row's selected state (so the user can extend a selection
963
- // without needing a second long-press-and-menu dance).
964
- // - Otherwise enter multi-select mode: mark THIS row selected
965
- // and add a sticky class on the body so future taps toggle
966
- // instead of opening messages. Tap elsewhere or press
967
- // Escape to exit.
968
1009
  const body = row.parentElement as HTMLElement | null;
969
1010
  const alreadyMulti = body?.classList.contains("multi-select-on");
970
1011
  if (alreadyMulti) {
971
- row.classList.toggle("selected");
1012
+ this.setSelected(!this.isSelected);
972
1013
  } else {
973
1014
  clearSelection();
974
- row.classList.add("selected");
1015
+ this.setSelected(true);
975
1016
  body?.classList.add("multi-select-on");
976
1017
  }
977
- lastClickedRow = row;
1018
+ lastClickedRow = this.el;
978
1019
  updateBulkBar();
979
- // Haptic hint if the platform supports it (Android WebView does).
980
1020
  try { (navigator as any).vibrate?.(20); } catch { /* */ }
981
1021
  }, LONG_PRESS_MS);
982
1022
  }, { passive: true });
@@ -986,127 +1026,134 @@ function appendMessages(body: HTMLElement, accountId: string, items: any[]): voi
986
1026
  row.addEventListener("touchmove", cancelLongPress, { passive: true });
987
1027
  row.addEventListener("touchend", cancelLongPress, { passive: true });
988
1028
  row.addEventListener("touchcancel", cancelLongPress, { passive: true });
1029
+ }
989
1030
 
990
- // ── Right-click context menu ──
991
- row.addEventListener("contextmenu", (e) => {
992
- e.preventDefault();
993
- // Selection-on-context-click semantics:
994
- // - If the right-clicked row is already selected → keep the
995
- // existing selection (single or multi). The menu acts on
996
- // whatever is selected.
997
- // - If it's NOT selected and we're in multi-select mode →
998
- // ADD this row to the selection (don't clear). Long-press
999
- // to open a context menu used to wipe the multi-select set,
1000
- // which was the user-reported "annoying" behavior.
1001
- // - If it's NOT selected and we're NOT in multi-select →
1002
- // single-select this row (replace prior selection).
1003
- const body = row.parentElement as HTMLElement | null;
1004
- const inMulti = !!body?.classList.contains("multi-select-on");
1005
- if (!row.classList.contains("selected")) {
1006
- if (inMulti) {
1007
- row.classList.add("selected");
1008
- lastClickedRow = row;
1009
- } else {
1010
- clearSelection();
1011
- row.classList.add("selected");
1012
- lastClickedRow = row;
1013
- focusMessage(msgAccountId, msg);
1014
- }
1031
+ private onRowContextMenu(e: MouseEvent): void {
1032
+ e.preventDefault();
1033
+ const body = this.el.parentElement as HTMLElement | null;
1034
+ const inMulti = !!body?.classList.contains("multi-select-on");
1035
+ if (!this.isSelected) {
1036
+ if (inMulti) {
1037
+ this.setSelected(true);
1038
+ lastClickedRow = this.el;
1039
+ } else {
1040
+ clearSelection();
1041
+ lastClickedRow = this.el;
1042
+ focusRow(this);
1015
1043
  }
1044
+ }
1016
1045
 
1017
- const isSeen = msg.flags.includes("\\Seen");
1018
- const isFlagged = msg.flags.includes("\\Flagged");
1019
-
1020
- const items: MenuItem[] = [
1021
- {
1022
- label: isSeen ? "Mark unread" : "Mark read",
1023
- action: async () => {
1024
- const newFlags = isSeen
1025
- ? msg.flags.filter((f: string) => f !== "\\Seen")
1026
- : [...msg.flags, "\\Seen"];
1027
- try {
1028
- await updateFlags(msgAccountId, msg.uid, newFlags);
1029
- msg.flags = newFlags;
1030
- state.updateMessageFlags(msgAccountId, msg.uid, newFlags);
1031
- row.classList.toggle("unread", !newFlags.includes("\\Seen"));
1032
- } catch { /* ignore */ }
1033
- },
1034
- },
1035
- {
1036
- label: isFlagged ? "Unflag" : "Flag",
1037
- action: async () => {
1038
- const newFlags = isFlagged
1039
- ? msg.flags.filter((f: string) => f !== "\\Flagged")
1040
- : [...msg.flags, "\\Flagged"];
1041
- try {
1042
- await updateFlags(msgAccountId, msg.uid, newFlags);
1043
- msg.flags = newFlags;
1044
- row.classList.toggle("flagged");
1045
- flag.textContent = row.classList.contains("flagged") ? "\u2605" : "\u2606";
1046
- } catch { /* ignore */ }
1047
- },
1048
- },
1049
- { label: "", action: () => {}, separator: true },
1050
- {
1051
- label: "Reply",
1052
- action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })),
1053
- },
1054
- {
1055
- label: "Reply All",
1056
- action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })),
1046
+ const isSeen = this.msg.flags.includes("\\Seen");
1047
+ const isFlagged = this.msg.flags.includes("\\Flagged");
1048
+ const accountId = this.accountId;
1049
+ const msg = this.msg;
1050
+ const self = this;
1051
+
1052
+ const items: MenuItem[] = [
1053
+ {
1054
+ label: isSeen ? "Mark unread" : "Mark read",
1055
+ action: async () => {
1056
+ const newFlags = isSeen
1057
+ ? msg.flags.filter((f: string) => f !== "\\Seen")
1058
+ : [...msg.flags, "\\Seen"];
1059
+ try {
1060
+ await updateFlags(accountId, msg.uid, newFlags);
1061
+ msg.flags = newFlags;
1062
+ state.updateMessageFlags(accountId, msg.uid, newFlags);
1063
+ self.setUnreadClass(!newFlags.includes("\\Seen"));
1064
+ } catch { /* ignore */ }
1057
1065
  },
1058
- {
1059
- label: "Forward",
1060
- action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })),
1066
+ },
1067
+ {
1068
+ label: isFlagged ? "Unflag" : "Flag",
1069
+ action: async () => {
1070
+ const newFlags = isFlagged
1071
+ ? msg.flags.filter((f: string) => f !== "\\Flagged")
1072
+ : [...msg.flags, "\\Flagged"];
1073
+ try {
1074
+ await updateFlags(accountId, msg.uid, newFlags);
1075
+ msg.flags = newFlags;
1076
+ self.setFlaggedClass(newFlags.includes("\\Flagged"));
1077
+ } catch { /* ignore */ }
1061
1078
  },
1062
- { label: "", action: () => {}, separator: true },
1063
- {
1064
- label: "Move to folder…",
1065
- action: async () => {
1066
- // Move all currently-selected rows (or just this one if it's the only selection)
1067
- const selectedRows = Array.from(document.querySelectorAll(".ml-row.selected"));
1068
- const uids = selectedRows.length > 0
1069
- ? selectedRows.map((r: Element) => Number((r as HTMLElement).dataset.uid)).filter(u => !isNaN(u))
1070
- : [msg.uid];
1071
- const pick = await pickFolder(msgAccountId, { excludeFolderIds: [msg.folderId] });
1072
- if (!pick) return;
1073
- try {
1074
- await apiMoveMessages(msgAccountId, uids, pick.folderId);
1075
- // Remove from local state reconciler handles server sync.
1076
- state.removeMessages(uids.map(u => ({ accountId: msgAccountId, uid: u })));
1077
- } catch (err: any) {
1078
- alert(`Move failed: ${err.message}`);
1079
- }
1080
- },
1079
+ },
1080
+ { label: "", action: () => {}, separator: true },
1081
+ { label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) },
1082
+ { label: "Reply All", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })) },
1083
+ { label: "Forward", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })) },
1084
+ { label: "", action: () => {}, separator: true },
1085
+ {
1086
+ label: "Move to folder…",
1087
+ action: async () => {
1088
+ const selectedRows = Array.from(document.querySelectorAll(".ml-row.selected"));
1089
+ const uids = selectedRows.length > 0
1090
+ ? selectedRows.map((r: Element) => Number((r as HTMLElement).dataset.uid)).filter(u => !isNaN(u))
1091
+ : [msg.uid];
1092
+ const pick = await pickFolder(accountId, { excludeFolderIds: [msg.folderId] });
1093
+ if (!pick) return;
1094
+ try {
1095
+ await apiMoveMessages(accountId, uids, pick.folderId);
1096
+ removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));
1097
+ } catch (err: any) {
1098
+ alert(`Move failed: ${err.message}`);
1099
+ }
1081
1100
  },
1082
- {
1083
- label: "Delete",
1084
- action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
1101
+ },
1102
+ { label: "Delete", action: () => document.dispatchEvent(new CustomEvent("mailx-delete")) },
1103
+ { label: "", action: () => {}, separator: true },
1104
+ { label: "⚠ Mark as spam", action: () => document.getElementById("btn-spam")?.click() },
1105
+ { label: "", action: () => {}, separator: true },
1106
+ {
1107
+ label: "Copy Message-ID",
1108
+ action: async () => {
1109
+ if (!msg.messageId) { alert("No Message-ID on this row."); return; }
1110
+ try { await navigator.clipboard.writeText(msg.messageId); } catch { /* */ }
1085
1111
  },
1086
- { label: "", action: () => {}, separator: true },
1087
- {
1088
- label: "⚠ Mark as spam",
1089
- action: () => document.getElementById("btn-spam")?.click(),
1090
- },
1091
- { label: "", action: () => {}, separator: true },
1092
- {
1093
- label: "Copy Message-ID",
1094
- action: async () => {
1095
- // Useful when asking "where did my letter go?" — pair the
1096
- // Message-ID with the reconcile-delete log line.
1097
- if (!msg.messageId) { alert("No Message-ID on this row."); return; }
1098
- try { await navigator.clipboard.writeText(msg.messageId); } catch { /* */ }
1099
- },
1100
- },
1101
- ];
1112
+ },
1113
+ ];
1102
1114
 
1103
- showContextMenu(e.clientX, e.clientY, items);
1104
- });
1115
+ showContextMenu(e.clientX, e.clientY, items);
1116
+ }
1117
+ }
1105
1118
 
1106
- body.appendChild(row);
1119
+ function appendMessages(body: HTMLElement, accountId: string, items: any[]): void {
1120
+ // Thread grouping: when the list has the "threaded" class, collapse
1121
+ // messages sharing the same threadId to a single row showing the most
1122
+ // recent message, with a small pill indicating the thread size.
1123
+ const threaded = body.classList.contains("threaded");
1124
+ let rowsToRender: any[] = items;
1125
+ let threadSize: Map<any, number> | null = null;
1126
+ if (threaded) {
1127
+ const threadMap = new Map<string, any>();
1128
+ threadSize = new Map<any, number>();
1129
+ for (const msg of items) {
1130
+ const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
1131
+ const existing = threadMap.get(key);
1132
+ if (!existing || (msg.date || 0) > (existing.date || 0)) {
1133
+ threadMap.set(key, msg);
1134
+ }
1135
+ }
1136
+ for (const msg of items) {
1137
+ const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
1138
+ const head = threadMap.get(key);
1139
+ if (head) threadSize.set(head, (threadSize.get(head) || 0) + 1);
1140
+ }
1141
+ rowsToRender = Array.from(threadMap.values()).sort((a, b) => (b.date || 0) - (a.date || 0));
1142
+ }
1143
+ for (const msg of rowsToRender) {
1144
+ const msgAccountId = msg.accountId || accountId;
1145
+ const threadCount = threadSize ? (threadSize.get(msg) || 1) : 1;
1146
+ const isThreadHead = threadCount > 1 && !!msg.threadId;
1147
+ // showAccountTag: true when rendering the unified inbox (no
1148
+ // single accountId for the page; each row carries its own and
1149
+ // gets a one-letter account chip).
1150
+ const showAccountTag = !accountId && !!msgAccountId;
1151
+ const row = new MessageRow(msg, msgAccountId, isThreadHead, threadCount, showAccountTag);
1152
+ row.attach(body);
1107
1153
  }
1108
1154
  }
1109
1155
 
1156
+
1110
1157
  function formatDate(epochMs: number): string {
1111
1158
  const d = new Date(epochMs);
1112
1159
  const now = new Date();