@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.
- package/client/app.js +41 -37
- package/client/app.js.map +1 -1
- package/client/app.ts +39 -36
- package/client/components/folder-tree.js +5 -3
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +5 -3
- package/client/components/message-list.js +485 -448
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +474 -427
- package/client/components/message-viewer.js +36 -41
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +36 -38
- package/client/lib/message-state.js +46 -65
- package/client/lib/message-state.js.map +1 -1
- package/client/lib/message-state.ts +67 -74
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
35
|
+
/** Single source of truth for "which row is focused" in the list.
|
|
35
36
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
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
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
*
|
|
319
|
-
*
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
el.
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
712
|
-
|
|
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") ? "
|
|
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 (
|
|
775
|
+
if (showAccountTag && accountId) {
|
|
791
776
|
const tag = document.createElement("span");
|
|
792
777
|
tag.className = "ml-account-tag";
|
|
793
|
-
tag.textContent =
|
|
794
|
-
tag.title =
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
threadPill
|
|
827
|
-
|
|
828
|
-
|
|
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 = `
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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(
|
|
905
|
-
lastClickedRow =
|
|
906
|
-
|
|
952
|
+
focusRow(this);
|
|
953
|
+
lastClickedRow = this.el;
|
|
954
|
+
this.setUnreadClass(false);
|
|
907
955
|
}
|
|
908
|
-
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
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", (
|
|
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
|
-
|
|
1012
|
+
this.setSelected(!this.isSelected);
|
|
972
1013
|
} else {
|
|
973
1014
|
clearSelection();
|
|
974
|
-
|
|
1015
|
+
this.setSelected(true);
|
|
975
1016
|
body?.classList.add("multi-select-on");
|
|
976
1017
|
}
|
|
977
|
-
lastClickedRow =
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1115
|
+
showContextMenu(e.clientX, e.clientY, items);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1105
1118
|
|
|
1106
|
-
|
|
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();
|