@bobfrankston/mailx 1.0.395 → 1.0.405
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/android.html +12 -1
- package/client/app.js +44 -4
- package/client/components/alarms.js +286 -0
- package/client/components/calendar-sidebar.js +43 -7
- package/client/components/message-list.js +215 -16
- package/client/components/message-viewer.js +120 -18
- package/client/compose/compose.js +137 -41
- package/client/index.html +12 -1
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +251 -6
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +18 -2
- package/packages/mailx-server/index.js +29 -0
- package/packages/mailx-service/index.d.ts +4 -0
- package/packages/mailx-service/index.js +6 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.d.ts +8 -0
- package/packages/mailx-store/db.js +34 -1
- package/packages/mailx-store-web/android-bootstrap.js +193 -93
- package/packages/mailx-store-web/db.d.ts +4 -0
- package/packages/mailx-store-web/db.js +25 -0
- package/packages/mailx-store-web/sync-manager.d.ts +7 -0
- package/packages/mailx-store-web/sync-manager.js +55 -0
- package/packages/mailx-store-web/web-service.d.ts +4 -0
- package/packages/mailx-store-web/web-service.js +7 -0
- package/tdview.cmd +1 -0
- package/unwedge.cmd +1 -0
|
@@ -24,15 +24,46 @@ let touchWasScroll = false;
|
|
|
24
24
|
// (text columns default asc, date defaults desc).
|
|
25
25
|
let currentSort = "date";
|
|
26
26
|
let currentSortDir = "desc";
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
27
|
+
/** S56 refactor slice — per-row focus/unfocus.
|
|
28
|
+
*
|
|
29
|
+
* Every rendered message row has an associated Row record tracking the DOM
|
|
30
|
+
* element + account/msg pair. `focusRow` runs the atomic transition:
|
|
31
|
+
*
|
|
32
|
+
* 1. unfocus the previously-focused row (clears `.selected` class)
|
|
33
|
+
* 2. mark the new row `.selected`
|
|
34
|
+
* 3. update shared state.selected + notify viewer
|
|
35
|
+
*
|
|
36
|
+
* The viewer's `showMessageGeneration` token still cancels stale body
|
|
37
|
+
* fetches during the transition — that's the "async cancellation" piece of
|
|
38
|
+
* S56. The "atomic DOM transition" piece is now owned here.
|
|
39
|
+
*
|
|
40
|
+
* Full abort-signal plumbing through getMessage → fetchMessageBody is a
|
|
41
|
+
* separate follow-up; for now the gen token does the job. */
|
|
42
|
+
let currentFocusedRow = null;
|
|
43
|
+
function focusRow(row, accountId, msg) {
|
|
44
|
+
if (currentFocusedRow && currentFocusedRow !== row) {
|
|
45
|
+
// Unfocus the previous atomically — clearing .selected AND
|
|
46
|
+
// triggering the viewer's stale-fetch cancel (via the bump inside
|
|
47
|
+
// the next showMessage() call).
|
|
48
|
+
currentFocusedRow.classList.remove("selected");
|
|
49
|
+
}
|
|
50
|
+
if (!row.classList.contains("selected"))
|
|
51
|
+
row.classList.add("selected");
|
|
52
|
+
currentFocusedRow = row;
|
|
53
|
+
state.select(msg);
|
|
54
|
+
onMessageSelect(accountId, msg.uid, msg.folderId);
|
|
55
|
+
}
|
|
56
|
+
/** Back-compat shim — some call sites still use `focusMessage(accountId, msg)`
|
|
57
|
+
* without a DOM row (e.g. thread-popup click). Fall back to the previous
|
|
58
|
+
* behavior (state + viewer only) so nothing regresses. */
|
|
33
59
|
function focusMessage(accountId, msg) {
|
|
34
60
|
state.select(msg);
|
|
35
61
|
onMessageSelect(accountId, msg.uid, msg.folderId);
|
|
62
|
+
// Clear the focused-row reference since we don't have a DOM row here
|
|
63
|
+
// — the next row click will unfocus whatever was selected anyway.
|
|
64
|
+
if (currentFocusedRow)
|
|
65
|
+
currentFocusedRow.classList.remove("selected");
|
|
66
|
+
currentFocusedRow = null;
|
|
36
67
|
}
|
|
37
68
|
/** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
|
|
38
69
|
* Called from the bodyCached service event — covers both background prefetch
|
|
@@ -64,6 +95,21 @@ function clearSelection() {
|
|
|
64
95
|
const body = document.getElementById("ml-body");
|
|
65
96
|
if (body)
|
|
66
97
|
body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
|
|
98
|
+
// S56 seam: the focused-row invariant is "the Row whose .selected is
|
|
99
|
+
// currently mine". clearSelection wipes all .selected, so the invariant
|
|
100
|
+
// would break if we kept a stale pointer.
|
|
101
|
+
currentFocusedRow = null;
|
|
102
|
+
}
|
|
103
|
+
/** Deterministic sender-avatar color from a seed string (typically the
|
|
104
|
+
* email address). Hash → hue at 12 evenly-spaced positions on the wheel.
|
|
105
|
+
* Saturation + lightness fixed so all colors carry the same visual weight
|
|
106
|
+
* regardless of hue, and so light/dark themes don't have to override. */
|
|
107
|
+
function senderColor(seed) {
|
|
108
|
+
let h = 0;
|
|
109
|
+
for (let i = 0; i < seed.length; i++)
|
|
110
|
+
h = (h * 31 + seed.charCodeAt(i)) | 0;
|
|
111
|
+
const hue = ((Math.abs(h) % 12) * 30) + 15; // 15, 45, 75, …, 345
|
|
112
|
+
return `oklch(0.62 0.14 ${hue})`;
|
|
67
113
|
}
|
|
68
114
|
/** Exit multi-select mode (entered via touch long-press). Clears selection
|
|
69
115
|
* and the sticky body flag so subsequent taps open messages again. */
|
|
@@ -73,6 +119,28 @@ function exitMultiSelect() {
|
|
|
73
119
|
return;
|
|
74
120
|
body.classList.remove("multi-select-on");
|
|
75
121
|
clearSelection();
|
|
122
|
+
updateBulkBar();
|
|
123
|
+
}
|
|
124
|
+
/** Refresh the bulk-actions bar visibility + "N selected" label. Called
|
|
125
|
+
* whenever selection or mode changes. Visible either when 2+ rows are
|
|
126
|
+
* selected (desktop Ctrl/Shift-click multi-selection) OR when touch
|
|
127
|
+
* multi-select mode is active (even with a single row, so the user sees
|
|
128
|
+
* the bar as the mode indicator). */
|
|
129
|
+
function updateBulkBar() {
|
|
130
|
+
const body = document.getElementById("ml-body");
|
|
131
|
+
const bar = document.getElementById("ml-bulkbar");
|
|
132
|
+
const count = document.getElementById("ml-bulk-count");
|
|
133
|
+
if (!body || !bar || !count)
|
|
134
|
+
return;
|
|
135
|
+
const active = body.classList.contains("multi-select-on");
|
|
136
|
+
const n = body.querySelectorAll(".ml-row.selected").length;
|
|
137
|
+
if (n >= 2 || (active && n > 0)) {
|
|
138
|
+
bar.hidden = false;
|
|
139
|
+
count.textContent = `${n} selected`;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
bar.hidden = true;
|
|
143
|
+
}
|
|
76
144
|
}
|
|
77
145
|
// Escape key + click-outside-list exit multi-select mode. Attached once
|
|
78
146
|
// (idempotent because document only has one listener scope per handler).
|
|
@@ -88,10 +156,89 @@ if (!window.__mailxMultiSelectWired) {
|
|
|
88
156
|
return;
|
|
89
157
|
const target = e.target;
|
|
90
158
|
// A tap on a row is handled by the row's own click listener; only
|
|
91
|
-
// exit when the tap is on neutral ground (outside the list entirely
|
|
92
|
-
|
|
159
|
+
// exit when the tap is on neutral ground (outside the list entirely
|
|
160
|
+
// and not on the bulk bar).
|
|
161
|
+
if (!target.closest(".ml-row") && !target.closest(".ml-bulkbar"))
|
|
93
162
|
exitMultiSelect();
|
|
94
163
|
}, true);
|
|
164
|
+
// Wire bulk-bar buttons — each delegates to the single-message handlers
|
|
165
|
+
// but iterates over every .ml-row.selected in the body.
|
|
166
|
+
document.addEventListener("click", async (e) => {
|
|
167
|
+
const target = e.target;
|
|
168
|
+
if (target.closest("#ml-bulk-cancel")) {
|
|
169
|
+
exitMultiSelect();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const btn = target.closest(".ml-bulk-btn");
|
|
173
|
+
if (!btn)
|
|
174
|
+
return;
|
|
175
|
+
const op = btn.dataset.bulk;
|
|
176
|
+
const body = document.getElementById("ml-body");
|
|
177
|
+
if (!body?.classList.contains("multi-select-on"))
|
|
178
|
+
return;
|
|
179
|
+
const selected = getSelectedMessages();
|
|
180
|
+
if (selected.length === 0)
|
|
181
|
+
return;
|
|
182
|
+
try {
|
|
183
|
+
if (op === "delete") {
|
|
184
|
+
// Delegate to the existing Del-key handler so the same
|
|
185
|
+
// tombstone/undo logic runs for bulk deletes.
|
|
186
|
+
document.dispatchEvent(new CustomEvent("mailx-delete"));
|
|
187
|
+
}
|
|
188
|
+
else if (op === "flag") {
|
|
189
|
+
for (const m of selected) {
|
|
190
|
+
const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
|
|
191
|
+
if (!row)
|
|
192
|
+
continue;
|
|
193
|
+
const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
|
|
194
|
+
if (!msgData)
|
|
195
|
+
continue;
|
|
196
|
+
const isFlagged = msgData.flags?.includes("\\Flagged");
|
|
197
|
+
const newFlags = isFlagged
|
|
198
|
+
? msgData.flags.filter((f) => f !== "\\Flagged")
|
|
199
|
+
: [...(msgData.flags || []), "\\Flagged"];
|
|
200
|
+
await updateFlags(m.accountId, m.uid, newFlags);
|
|
201
|
+
msgData.flags = newFlags;
|
|
202
|
+
state.updateMessageFlags(m.accountId, m.uid, newFlags);
|
|
203
|
+
row.classList.toggle("flagged");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else if (op === "markread") {
|
|
207
|
+
for (const m of selected) {
|
|
208
|
+
const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
|
|
209
|
+
if (!row)
|
|
210
|
+
continue;
|
|
211
|
+
const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
|
|
212
|
+
if (!msgData)
|
|
213
|
+
continue;
|
|
214
|
+
if (msgData.flags?.includes("\\Seen"))
|
|
215
|
+
continue;
|
|
216
|
+
const newFlags = [...(msgData.flags || []), "\\Seen"];
|
|
217
|
+
await updateFlags(m.accountId, m.uid, newFlags);
|
|
218
|
+
msgData.flags = newFlags;
|
|
219
|
+
state.updateMessageFlags(m.accountId, m.uid, newFlags);
|
|
220
|
+
row.classList.remove("unread");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else if (op === "move") {
|
|
224
|
+
// Use the first selection's account/folder for the picker scope.
|
|
225
|
+
const { accountId, folderId } = selected[0];
|
|
226
|
+
const pick = await pickFolder(accountId, { excludeFolderIds: [folderId] });
|
|
227
|
+
if (!pick)
|
|
228
|
+
return;
|
|
229
|
+
const uids = selected.map(s => s.uid);
|
|
230
|
+
await apiMoveMessages(accountId, uids, pick.folderId);
|
|
231
|
+
state.removeMessages(uids.map(u => ({ accountId, uid: u })));
|
|
232
|
+
}
|
|
233
|
+
else if (op === "spam") {
|
|
234
|
+
document.getElementById("btn-spam")?.click();
|
|
235
|
+
}
|
|
236
|
+
exitMultiSelect();
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
alert(`Bulk ${op} failed: ${err.message || err}`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
95
242
|
}
|
|
96
243
|
function selectRange(from, to) {
|
|
97
244
|
const body = document.getElementById("ml-body");
|
|
@@ -380,6 +527,10 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
380
527
|
export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
|
|
381
528
|
searchMode = false;
|
|
382
529
|
unifiedMode = false;
|
|
530
|
+
// Folder switch clears any in-progress multi-select — carrying a "3
|
|
531
|
+
// selected" state across folders would lie about what rows the bulk
|
|
532
|
+
// buttons would act on.
|
|
533
|
+
exitMultiSelect();
|
|
383
534
|
// specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
|
|
384
535
|
// folder path lowercased (folder-tree fallback when tag is missing — common
|
|
385
536
|
// on Dovecot which doesn't advertise \Sent). Match both cases.
|
|
@@ -583,6 +734,41 @@ function appendMessages(body, accountId, items) {
|
|
|
583
734
|
row.dataset.folderId = String(msg.folderId);
|
|
584
735
|
if (msg.threadId)
|
|
585
736
|
row.dataset.threadId = msg.threadId;
|
|
737
|
+
// Sender avatar \u2014 Thunderbird-style colored circle with the first
|
|
738
|
+
// initial of the sender's display name. Doubles as the multi-select
|
|
739
|
+
// affordance: in `multi-select-on` mode, the avatar swaps to a
|
|
740
|
+
// checkmark via CSS. Color is derived deterministically from the
|
|
741
|
+
// address so the same sender keeps the same color across rows.
|
|
742
|
+
const fromName = (showToInsteadOfFrom && msg.to?.length)
|
|
743
|
+
? (msg.to[0].name || msg.to[0].address || "?")
|
|
744
|
+
: (msg.from?.name || msg.from?.address || "?");
|
|
745
|
+
const seedAddr = (msg.from?.address || msg.from?.name || "?").toLowerCase();
|
|
746
|
+
const initial = (fromName.replace(/^[\W_]+/, "") || "?").charAt(0).toUpperCase();
|
|
747
|
+
const avatar = document.createElement("span");
|
|
748
|
+
avatar.className = "ml-avatar";
|
|
749
|
+
avatar.textContent = initial;
|
|
750
|
+
avatar.style.background = senderColor(seedAddr);
|
|
751
|
+
avatar.title = msg.from?.address || "";
|
|
752
|
+
// Tapping the avatar enters multi-select mode (or toggles in it,
|
|
753
|
+
// mirroring Thunderbird/Gmail). Click bubbles to the row otherwise,
|
|
754
|
+
// which would open the message — stopPropagation here keeps the
|
|
755
|
+
// avatar a dedicated selection affordance.
|
|
756
|
+
avatar.addEventListener("click", (e) => {
|
|
757
|
+
e.stopPropagation();
|
|
758
|
+
const body = document.getElementById("ml-body");
|
|
759
|
+
if (!body)
|
|
760
|
+
return;
|
|
761
|
+
if (body.classList.contains("multi-select-on")) {
|
|
762
|
+
row.classList.toggle("selected");
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
clearSelection();
|
|
766
|
+
row.classList.add("selected");
|
|
767
|
+
body.classList.add("multi-select-on");
|
|
768
|
+
}
|
|
769
|
+
lastClickedRow = row;
|
|
770
|
+
updateBulkBar();
|
|
771
|
+
});
|
|
586
772
|
const flag = document.createElement("span");
|
|
587
773
|
flag.className = "ml-flag";
|
|
588
774
|
flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
|
|
@@ -611,6 +797,15 @@ function appendMessages(body, accountId, items) {
|
|
|
611
797
|
folderTag.title = `In folder: ${msg.folderName}`;
|
|
612
798
|
from.prepend(folderTag);
|
|
613
799
|
}
|
|
800
|
+
// Unified inbox: same Message-ID exists under >=2 accounts → ⇆ badge.
|
|
801
|
+
// Tooltip names the count so the user knows "this appears on N".
|
|
802
|
+
if (msg.dupeCount >= 2) {
|
|
803
|
+
const dupe = document.createElement("span");
|
|
804
|
+
dupe.className = "ml-dupe-tag";
|
|
805
|
+
dupe.textContent = "⇆";
|
|
806
|
+
dupe.title = `Same message on ${msg.dupeCount} accounts`;
|
|
807
|
+
from.prepend(dupe);
|
|
808
|
+
}
|
|
614
809
|
const subject = document.createElement("span");
|
|
615
810
|
subject.className = "ml-subject";
|
|
616
811
|
subject.innerHTML = escapeHtml(msg.subject);
|
|
@@ -655,6 +850,7 @@ function appendMessages(body, accountId, items) {
|
|
|
655
850
|
}
|
|
656
851
|
catch { /* ignore */ }
|
|
657
852
|
});
|
|
853
|
+
row.appendChild(avatar);
|
|
658
854
|
row.appendChild(flag);
|
|
659
855
|
row.appendChild(from);
|
|
660
856
|
row.appendChild(date);
|
|
@@ -671,22 +867,28 @@ function appendMessages(body, accountId, items) {
|
|
|
671
867
|
if (body?.classList.contains("multi-select-on")) {
|
|
672
868
|
row.classList.toggle("selected");
|
|
673
869
|
lastClickedRow = row;
|
|
870
|
+
updateBulkBar();
|
|
674
871
|
return;
|
|
675
872
|
}
|
|
676
873
|
if (e.shiftKey && lastClickedRow) {
|
|
677
874
|
clearSelection();
|
|
678
875
|
selectRange(lastClickedRow, row);
|
|
876
|
+
lastClickedRow = row;
|
|
877
|
+
row.classList.remove("unread");
|
|
878
|
+
focusMessage(msgAccountId, msg);
|
|
679
879
|
}
|
|
680
880
|
else if (e.ctrlKey || e.metaKey) {
|
|
681
881
|
row.classList.toggle("selected");
|
|
882
|
+
lastClickedRow = row;
|
|
682
883
|
}
|
|
683
884
|
else {
|
|
885
|
+
// Atomic unfocus-previous + focus-this.
|
|
684
886
|
clearSelection();
|
|
685
|
-
row
|
|
887
|
+
focusRow(row, msgAccountId, msg);
|
|
888
|
+
lastClickedRow = row;
|
|
889
|
+
row.classList.remove("unread");
|
|
686
890
|
}
|
|
687
|
-
|
|
688
|
-
row.classList.remove("unread");
|
|
689
|
-
focusMessage(msgAccountId, msg);
|
|
891
|
+
updateBulkBar();
|
|
690
892
|
});
|
|
691
893
|
// Q64: double-click → pop out the message in a floating overlay so
|
|
692
894
|
// the user can read it without losing the selected list context.
|
|
@@ -756,6 +958,7 @@ function appendMessages(body, accountId, items) {
|
|
|
756
958
|
body?.classList.add("multi-select-on");
|
|
757
959
|
}
|
|
758
960
|
lastClickedRow = row;
|
|
961
|
+
updateBulkBar();
|
|
759
962
|
// Haptic hint if the platform supports it (Android WebView does).
|
|
760
963
|
try {
|
|
761
964
|
navigator.vibrate?.(20);
|
|
@@ -859,10 +1062,6 @@ function appendMessages(body, accountId, items) {
|
|
|
859
1062
|
label: "⚠ Mark as spam",
|
|
860
1063
|
action: () => document.getElementById("btn-spam")?.click(),
|
|
861
1064
|
},
|
|
862
|
-
{
|
|
863
|
-
label: "🚫 Report spam",
|
|
864
|
-
action: () => document.getElementById("btn-spam-report")?.click(),
|
|
865
|
-
},
|
|
866
1065
|
{ label: "", action: () => { }, separator: true },
|
|
867
1066
|
{
|
|
868
1067
|
label: "Copy Message-ID",
|
|
@@ -191,6 +191,17 @@ function installPreviewControls(iframe) {
|
|
|
191
191
|
const pct = Math.round(previewZoom * 100);
|
|
192
192
|
const sel = doc.defaultView?.getSelection();
|
|
193
193
|
const selectedText = sel?.toString().trim() || "";
|
|
194
|
+
const runSearch = (query) => {
|
|
195
|
+
const input = document.getElementById("search-input");
|
|
196
|
+
if (!input)
|
|
197
|
+
return;
|
|
198
|
+
input.value = query;
|
|
199
|
+
// Trigger the existing search path — Enter keydown hits the
|
|
200
|
+
// immediate branch in app.ts's handler.
|
|
201
|
+
input.dispatchEvent(new Event("input"));
|
|
202
|
+
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
203
|
+
input.focus();
|
|
204
|
+
};
|
|
194
205
|
const items = [
|
|
195
206
|
{ label: "Copy", action: () => doc.execCommand("copy") },
|
|
196
207
|
{ label: "Select all", action: () => {
|
|
@@ -202,14 +213,35 @@ function installPreviewControls(iframe) {
|
|
|
202
213
|
s.removeAllRanges();
|
|
203
214
|
s.addRange(range);
|
|
204
215
|
} },
|
|
205
|
-
{ label: "", action: () => { }, separator: true },
|
|
206
|
-
{ label: selectedText ? "Translate selection" : "Translate message",
|
|
207
|
-
action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) },
|
|
208
|
-
{ label: "", action: () => { }, separator: true },
|
|
209
|
-
{ label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
|
|
210
|
-
{ label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
|
|
211
|
-
{ label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
|
|
212
216
|
];
|
|
217
|
+
if (selectedText) {
|
|
218
|
+
items.push({ label: "", action: () => { }, separator: true },
|
|
219
|
+
// Truncate long selections in the label so the menu doesn't
|
|
220
|
+
// blow out; full string is what we search for.
|
|
221
|
+
{ label: `Search messages for "${selectedText.length > 40 ? selectedText.slice(0, 40) + "…" : selectedText}"`,
|
|
222
|
+
action: () => runSearch(selectedText) }, {
|
|
223
|
+
label: "Copy as quoted (> prefix)",
|
|
224
|
+
action: async () => {
|
|
225
|
+
// Prefix each line with "> " (RFC 3676 reply-quote).
|
|
226
|
+
// Useful when pasting a snippet into a compose window
|
|
227
|
+
// without the usual full-message blockquote wrapping.
|
|
228
|
+
const quoted = selectedText.split(/\r?\n/).map(l => "> " + l).join("\n");
|
|
229
|
+
try {
|
|
230
|
+
await navigator.clipboard.writeText(quoted);
|
|
231
|
+
}
|
|
232
|
+
catch { /* */ }
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const senderAddr = currentMessage?.from?.address || "";
|
|
237
|
+
if (senderAddr) {
|
|
238
|
+
items.push({
|
|
239
|
+
label: `Search messages from ${senderAddr}`,
|
|
240
|
+
action: () => runSearch(`from:${senderAddr}`),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
items.push({ label: "", action: () => { }, separator: true }, { label: selectedText ? "Translate selection" : "Translate message",
|
|
244
|
+
action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) }, { label: "", action: () => { }, separator: true }, { label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) }, { label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) }, { label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) });
|
|
213
245
|
showContextMenu(x, y, items);
|
|
214
246
|
});
|
|
215
247
|
};
|
|
@@ -239,8 +271,24 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
239
271
|
const headerEl = document.getElementById("mv-header");
|
|
240
272
|
const bodyEl = document.getElementById("mv-body");
|
|
241
273
|
const attEl = document.getElementById("mv-attachments");
|
|
242
|
-
|
|
243
|
-
|
|
274
|
+
// Envelope-first render: the row the user just clicked already has the
|
|
275
|
+
// subject / from / to / cc / date / preview in the message-state. Use
|
|
276
|
+
// that to populate the header + a snippet placeholder IMMEDIATELY so
|
|
277
|
+
// tapping a message never shows just "Fetching message body..." with
|
|
278
|
+
// nothing actionable. The full getMessage() call (which might block on
|
|
279
|
+
// a slow IMAP body fetch) only fills in the body and attachments.
|
|
280
|
+
const cached = state.getSelected();
|
|
281
|
+
if (cached && cached.uid === uid && (cached.accountId || accountId) === accountId) {
|
|
282
|
+
try {
|
|
283
|
+
renderHeaderFromEnvelope(headerEl, cached);
|
|
284
|
+
}
|
|
285
|
+
catch { /* */ }
|
|
286
|
+
bodyEl.innerHTML = `<div class="mv-empty">Fetching message body…<br><br><span style="color:var(--color-text-muted);font-size:0.9em">${escapeHtml(cached.preview || "")}</span></div>`;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
|
|
290
|
+
headerEl.hidden = true;
|
|
291
|
+
}
|
|
244
292
|
attEl.hidden = true;
|
|
245
293
|
try {
|
|
246
294
|
const msg = await getMessage(accountId, uid, false, folderId);
|
|
@@ -697,6 +745,29 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
697
745
|
console.error(`Attachment download failed: ${err.message}`);
|
|
698
746
|
}
|
|
699
747
|
});
|
|
748
|
+
// Drag the chip to an external target (Explorer / Finder / Files app)
|
|
749
|
+
// to drop the file there. Uses the Chromium `DownloadURL` dataTransfer
|
|
750
|
+
// format: "mime:filename:blob-url". We fetch the attachment first so
|
|
751
|
+
// the blob URL is valid by the time the drop lands.
|
|
752
|
+
chip.draggable = true;
|
|
753
|
+
chip.addEventListener("dragstart", async (e) => {
|
|
754
|
+
if (!e.dataTransfer)
|
|
755
|
+
return;
|
|
756
|
+
try {
|
|
757
|
+
const data = await getAttachment(accountId, uid, i, msg.folderId);
|
|
758
|
+
const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
|
|
759
|
+
const blob = new Blob([bytes], { type: data.contentType || "application/octet-stream" });
|
|
760
|
+
const url = URL.createObjectURL(blob);
|
|
761
|
+
// Sanitize filename: no path separators, no newlines.
|
|
762
|
+
const safeName = (att.filename || "attachment").replace(/[\r\n"\/\\]/g, "_");
|
|
763
|
+
const downloadUrl = `${data.contentType || "application/octet-stream"}:${safeName}:${url}`;
|
|
764
|
+
e.dataTransfer.setData("DownloadURL", downloadUrl);
|
|
765
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
console.error(`Attachment drag-out failed: ${err.message || err}`);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
700
771
|
attEl.appendChild(chip);
|
|
701
772
|
}
|
|
702
773
|
}
|
|
@@ -731,6 +802,40 @@ function formatAddr(addr) {
|
|
|
731
802
|
return `${addr.name} <${addr.address}>`;
|
|
732
803
|
return addr.address;
|
|
733
804
|
}
|
|
805
|
+
/** Render the viewer header from a list-row envelope (instant — no body
|
|
806
|
+
* fetch awaited). Used to populate the header pane the moment a message
|
|
807
|
+
* is clicked so the user always sees something actionable; getMessage()
|
|
808
|
+
* later overwrites the same fields with the authoritative values from the
|
|
809
|
+
* body parse (which can add Cc, Delivered-To, etc. that the list row
|
|
810
|
+
* doesn't track). */
|
|
811
|
+
function renderHeaderFromEnvelope(headerEl, env) {
|
|
812
|
+
headerEl.hidden = false;
|
|
813
|
+
const fromEl = headerEl.querySelector(".mv-from");
|
|
814
|
+
const toEl = headerEl.querySelector(".mv-to");
|
|
815
|
+
const subjEl = headerEl.querySelector(".mv-subject");
|
|
816
|
+
const dateEl = headerEl.querySelector(".mv-date");
|
|
817
|
+
if (fromEl)
|
|
818
|
+
fromEl.textContent = formatAddr(env.from);
|
|
819
|
+
if (toEl) {
|
|
820
|
+
let toLine = `To: ${(env.to || []).map(formatAddr).join(", ")}`;
|
|
821
|
+
if (env.cc?.length)
|
|
822
|
+
toLine += ` Cc: ${env.cc.map(formatAddr).join(", ")}`;
|
|
823
|
+
toEl.textContent = toLine;
|
|
824
|
+
}
|
|
825
|
+
if (subjEl)
|
|
826
|
+
subjEl.textContent = env.subject || "";
|
|
827
|
+
if (dateEl) {
|
|
828
|
+
try {
|
|
829
|
+
dateEl.textContent = new Date(env.date).toLocaleString();
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
dateEl.textContent = "";
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
function escapeHtml(s) {
|
|
837
|
+
return (s || "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[c]));
|
|
838
|
+
}
|
|
734
839
|
/** Convert plain text URLs into clickable links, escaping HTML */
|
|
735
840
|
function linkifyText(text) {
|
|
736
841
|
// Escape HTML first
|
|
@@ -922,15 +1027,12 @@ ${csp}
|
|
|
922
1027
|
document.addEventListener("touchcancel", function () {
|
|
923
1028
|
lastTouchTarget = null; touchMoved = false;
|
|
924
1029
|
}, { passive: true, capture: true });
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
window.parent.postMessage({ type: "linkHover", url: "" }, "*");
|
|
932
|
-
}
|
|
933
|
-
});
|
|
1030
|
+
// Link hover popover removed 2026-04-24 — user feedback: it persisted
|
|
1031
|
+
// over the message body when the dismissers (mousedown/scroll/blur)
|
|
1032
|
+
// didn't fire from inside the iframe, leaving a multi-line URL hanging
|
|
1033
|
+
// in the middle of the reading pane. Right-click (desktop) and long-
|
|
1034
|
+
// press (touch) on a link still open the existing C29 menu with Open /
|
|
1035
|
+
// Save-as / Copy URL / Copy link-text.
|
|
934
1036
|
// C29: right-click on a link → ask parent for the Open/Save/Copy menu.
|
|
935
1037
|
document.addEventListener("contextmenu", function (e) {
|
|
936
1038
|
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|