@bobfrankston/rmfmail 1.2.224 → 1.2.225
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-bootstrap.bundle.js +15 -2
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +64 -31
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +24 -10
- package/client/app.js.map +1 -1
- package/client/app.ts +21 -10
- package/client/components/message-list.js +56 -15
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +55 -16
- package/client/components/message-viewer.js +2 -2
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +2 -2
- package/client/lib/message-state.js +15 -4
- package/client/lib/message-state.js.map +1 -1
- package/client/lib/message-state.ts +12 -3
- package/package.json +5 -5
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/web-service.d.ts +10 -1
- package/packages/mailx-store-web/web-service.d.ts.map +1 -1
- package/packages/mailx-store-web/web-service.js +16 -1
- package/packages/mailx-store-web/web-service.js.map +1 -1
- package/packages/mailx-store-web/web-service.ts +15 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-142920 → node_modules.npmglobalize-stash-67672}/.package-lock.json +0 -0
package/client/app.ts
CHANGED
|
@@ -1857,8 +1857,8 @@ async function performUndo(): Promise<void> {
|
|
|
1857
1857
|
? [flag] // simplest restore — full set is unknown here
|
|
1858
1858
|
: [];
|
|
1859
1859
|
await updateFlags(entry.accountId, entry.uid, flagsAfter, entry.folderId);
|
|
1860
|
-
setRowFlagged(entry.accountId, entry.uid, entry.prevFlagged);
|
|
1861
|
-
messageState.updateMessageFlags(entry.accountId, entry.uid, flagsAfter);
|
|
1860
|
+
setRowFlagged(entry.accountId, entry.uid, entry.prevFlagged, entry.folderId);
|
|
1861
|
+
messageState.updateMessageFlags(entry.accountId, entry.uid, flagsAfter, entry.folderId);
|
|
1862
1862
|
}
|
|
1863
1863
|
if (statusSync) statusSync.textContent = `Undid flag change on ${op.payload.length} message${op.payload.length !== 1 ? "s" : ""}${remaining}`;
|
|
1864
1864
|
}
|
|
@@ -2075,8 +2075,8 @@ async function bulkFlagSelected(): Promise<void> {
|
|
|
2075
2075
|
for (const r of rows) {
|
|
2076
2076
|
if (flaggedOf(r) === targetFlagged) continue;
|
|
2077
2077
|
setFlagged(r, targetFlagged);
|
|
2078
|
-
setRowFlagged(r.accountId, r.uid, targetFlagged);
|
|
2079
|
-
messageState.updateMessageFlags(r.accountId, r.uid, r.flags);
|
|
2078
|
+
setRowFlagged(r.accountId, r.uid, targetFlagged, (r as any).folderId);
|
|
2079
|
+
messageState.updateMessageFlags(r.accountId, r.uid, r.flags, (r as any).folderId);
|
|
2080
2080
|
try { await updateFlags(r.accountId, r.uid, r.flags, (r as any).folderId); }
|
|
2081
2081
|
catch (e: any) { console.error(`Bulk flag failed for ${r.accountId}/${r.uid}: ${e?.message || e}`); }
|
|
2082
2082
|
}
|
|
@@ -2097,10 +2097,10 @@ document.getElementById("btn-flag")?.addEventListener("click", async () => {
|
|
|
2097
2097
|
updateFlagButton();
|
|
2098
2098
|
try {
|
|
2099
2099
|
await updateFlags(sel.accountId, sel.uid, sel.flags, (sel as any).folderId);
|
|
2100
|
-
messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);
|
|
2100
|
+
messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags, (sel as any).folderId);
|
|
2101
2101
|
// Row owns its own DOM \u2014 go through the row object so class + star
|
|
2102
2102
|
// update atomically and the list/preview stay in sync.
|
|
2103
|
-
setRowFlagged(sel.accountId, sel.uid, !wasFlagged);
|
|
2103
|
+
setRowFlagged(sel.accountId, sel.uid, !wasFlagged, (sel as any).folderId);
|
|
2104
2104
|
} catch (e: unknown) {
|
|
2105
2105
|
// Revert local state on failure so visual + data stay consistent.
|
|
2106
2106
|
setFlagged(sel, wasFlagged);
|
|
@@ -2172,9 +2172,20 @@ async function spamSelectedMessages(): Promise<void> {
|
|
|
2172
2172
|
// messages' current folder instead of guessing by uid alone.
|
|
2173
2173
|
if (result?.targetFolderId) spamUndo.targetFolderId = result.targetFolderId;
|
|
2174
2174
|
})
|
|
2175
|
-
.catch(e => {
|
|
2175
|
+
.catch(async e => {
|
|
2176
2176
|
console.error(`[spam] ${accountId} failed:`, e);
|
|
2177
|
-
|
|
2177
|
+
// The optimistic remove above already took these rows off the
|
|
2178
|
+
// list. If the move genuinely failed, leaving them gone is a
|
|
2179
|
+
// LIE — the message is still in the inbox and reappears at the
|
|
2180
|
+
// next sync looking like it "came back" (Bob 2026-08-07, where
|
|
2181
|
+
// the Android markAsSpamMessages stub threw on every click and
|
|
2182
|
+
// only a status-bar line said so). Put the list back and say it
|
|
2183
|
+
// loudly enough to be seen on a phone.
|
|
2184
|
+
if (statusSync) statusSync.textContent = `Spam FAILED (${accountId}): ${e?.message || e}`;
|
|
2185
|
+
try { await reloadCurrentFolder(); } catch { /* list rebuilds on next sync */ }
|
|
2186
|
+
window.dispatchEvent(new CustomEvent("mailx-alert", {
|
|
2187
|
+
detail: { message: `Couldn't move ${uids.length} message(s) to spam: ${e?.message || e}`, key: "spam-move" },
|
|
2188
|
+
}));
|
|
2178
2189
|
});
|
|
2179
2190
|
}
|
|
2180
2191
|
}
|
|
@@ -2236,7 +2247,7 @@ document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
|
|
|
2236
2247
|
const wasSeen = seenOf(sel);
|
|
2237
2248
|
setSeen(sel, !wasSeen);
|
|
2238
2249
|
updateFlags(sel.accountId, sel.uid, sel.flags, (sel as any).folderId).then(() => {
|
|
2239
|
-
messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);
|
|
2250
|
+
messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags, (sel as any).folderId);
|
|
2240
2251
|
const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`) as HTMLElement;
|
|
2241
2252
|
if (row) row.classList.toggle("unread", wasSeen);
|
|
2242
2253
|
}).catch(() => { setSeen(sel, wasSeen); });
|
|
@@ -5995,7 +6006,7 @@ document.addEventListener("mailx-popout-action", ((e: any) => {
|
|
|
5995
6006
|
setFlagged(msg, !wasFlagged);
|
|
5996
6007
|
updateFlags(accountId, msg.uid, msg.flags, msg.folderId).catch((err: any) =>
|
|
5997
6008
|
console.error(`[popout] flag failed: ${err?.message || err}`));
|
|
5998
|
-
setRowFlagged(accountId, msg.uid, !wasFlagged);
|
|
6009
|
+
setRowFlagged(accountId, msg.uid, !wasFlagged, msg.folderId);
|
|
5999
6010
|
}
|
|
6000
6011
|
}) as EventListener);
|
|
6001
6012
|
|
|
@@ -382,8 +382,40 @@ export async function refreshPriorityIndex() {
|
|
|
382
382
|
}
|
|
383
383
|
catch { /* service may not be ready yet */ }
|
|
384
384
|
}
|
|
385
|
-
|
|
386
|
-
|
|
385
|
+
/** Row lookup key. MUST include the folder: an IMAP UID is unique only WITHIN
|
|
386
|
+
* a folder, so in a unified view ("All Inboxes", search results) two entirely
|
|
387
|
+
* different messages routinely share one (account, uid) pair. Keyed on
|
|
388
|
+
* (account, uid) alone, the second row's attach() OVERWROTE the first in
|
|
389
|
+
* rowByKey — after which setRowFlagged / setRowSeen / focusByIdentity for
|
|
390
|
+
* either message resolved to whichever row registered last, and the loser's
|
|
391
|
+
* detach() deleted the winner's entry. That is why an accidental star
|
|
392
|
+
* "refused to go away" and why a second message from the same sender came up
|
|
393
|
+
* starred too (Bob 2026-08-07, All Inboxes). Same invariant the DB has had
|
|
394
|
+
* since the uid-is-not-identity corruption. */
|
|
395
|
+
function rowKey(accountId, uid, folderId) {
|
|
396
|
+
return `${accountId}:${folderId ?? ""}:${uid}`;
|
|
397
|
+
}
|
|
398
|
+
/** Resolve a row when the caller may not know the folder. With a folderId it
|
|
399
|
+
* is an exact hit; without one it scans, and a UID that matches more than one
|
|
400
|
+
* folder returns nothing rather than guessing — silently acting on the wrong
|
|
401
|
+
* message is the failure mode this whole key change exists to stop. */
|
|
402
|
+
function findRow(accountId, uid, folderId) {
|
|
403
|
+
if (folderId !== undefined && folderId !== null) {
|
|
404
|
+
const exact = rowByKey.get(rowKey(accountId, uid, folderId));
|
|
405
|
+
if (exact)
|
|
406
|
+
return exact;
|
|
407
|
+
}
|
|
408
|
+
const hits = [];
|
|
409
|
+
for (const row of rowByKey.values()) {
|
|
410
|
+
if (row.accountId === accountId && row.msg.uid === uid)
|
|
411
|
+
hits.push(row);
|
|
412
|
+
}
|
|
413
|
+
if (hits.length === 1)
|
|
414
|
+
return hits[0];
|
|
415
|
+
if (hits.length > 1) {
|
|
416
|
+
console.warn(`[message-list] ambiguous row lookup ${accountId}/uid=${uid} — ${hits.length} folders match; caller must pass folderId`);
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
387
419
|
}
|
|
388
420
|
function focusRow(row, opts = {}) {
|
|
389
421
|
// Single-select invariant: this row is the ONLY highlight, and it is the
|
|
@@ -458,7 +490,7 @@ export function getCurrentFocused() {
|
|
|
458
490
|
* keyboard nav, post-delete handoff. Returns true if a row was found
|
|
459
491
|
* and focused. */
|
|
460
492
|
function focusByIdentity(accountId, uid, opts = {}) {
|
|
461
|
-
const row =
|
|
493
|
+
const row = findRow(accountId, uid, opts.folderId);
|
|
462
494
|
if (!row)
|
|
463
495
|
return false;
|
|
464
496
|
focusRow(row, opts);
|
|
@@ -468,8 +500,8 @@ function focusByIdentity(accountId, uid, opts = {}) {
|
|
|
468
500
|
* the toolbar Flag button so the visible star tracks the data instead of
|
|
469
501
|
* drifting until the next list rebuild. Calls into the row object's own
|
|
470
502
|
* setFlaggedClass — atomic class+text update, can't desync. */
|
|
471
|
-
export function setRowFlagged(accountId, uid, yes) {
|
|
472
|
-
const row =
|
|
503
|
+
export function setRowFlagged(accountId, uid, yes, folderId) {
|
|
504
|
+
const row = findRow(accountId, uid, folderId);
|
|
473
505
|
if (row)
|
|
474
506
|
row.setFlaggedClass(yes);
|
|
475
507
|
}
|
|
@@ -480,8 +512,8 @@ export function setRowFlagged(accountId, uid, yes) {
|
|
|
480
512
|
* a right-click during the 2-s dwell window read the row as "read"
|
|
481
513
|
* and offered "Mark unread" on a message that wasn't actually marked
|
|
482
514
|
* yet. */
|
|
483
|
-
export function setRowSeen(accountId, uid, yes) {
|
|
484
|
-
const row =
|
|
515
|
+
export function setRowSeen(accountId, uid, yes, folderId) {
|
|
516
|
+
const row = findRow(accountId, uid, folderId);
|
|
485
517
|
if (row)
|
|
486
518
|
row.setUnreadClass(!yes);
|
|
487
519
|
}
|
|
@@ -490,7 +522,7 @@ export function setRowSeen(accountId, uid, yes) {
|
|
|
490
522
|
function selectedRows() {
|
|
491
523
|
const out = [];
|
|
492
524
|
for (const s of getSelectedMessages()) {
|
|
493
|
-
const row =
|
|
525
|
+
const row = findRow(s.accountId, s.uid, s.folderId);
|
|
494
526
|
if (row)
|
|
495
527
|
out.push(row);
|
|
496
528
|
}
|
|
@@ -503,7 +535,7 @@ function visibleRowObjs() {
|
|
|
503
535
|
if (!body)
|
|
504
536
|
return [];
|
|
505
537
|
return Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
|
|
506
|
-
.map(el => rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid))))
|
|
538
|
+
.map(el => rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid), el.dataset.folderId)))
|
|
507
539
|
.filter((r) => !!r);
|
|
508
540
|
}
|
|
509
541
|
/** The set a bulk action targets: the SELECTED rows if any are marked,
|
|
@@ -695,6 +727,15 @@ export function releaseFocus() {
|
|
|
695
727
|
const body = document.getElementById("ml-body");
|
|
696
728
|
body?.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
|
|
697
729
|
viewerClear();
|
|
730
|
+
// On a narrow screen the viewer is the ONLY visible pane (the list carries
|
|
731
|
+
// narrow-hidden), so clearing it leaves a white screen reading "Select a
|
|
732
|
+
// message to read" with no list to select from — and in viewer-fullscreen
|
|
733
|
+
// not even a back button. That is a dead end: the user had to fold/unfold
|
|
734
|
+
// the phone to escape (Bob 2026-08-07, after sending spam from the viewer).
|
|
735
|
+
// No message ⟹ nothing to view ⟹ go back to the list.
|
|
736
|
+
document.body.classList.remove("viewer-fullscreen");
|
|
737
|
+
document.getElementById("message-viewer")?.classList.remove("narrow-active");
|
|
738
|
+
document.getElementById("message-list")?.classList.remove("narrow-hidden");
|
|
698
739
|
document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: null }));
|
|
699
740
|
}
|
|
700
741
|
/** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
|
|
@@ -1071,7 +1112,7 @@ export function removeMessagesAndReconcile(uids) {
|
|
|
1071
1112
|
// scheme, but only detach via the row object if it actually
|
|
1072
1113
|
// owns THIS element (a cross-folder uid collision could point
|
|
1073
1114
|
// it at a different row). Otherwise remove the DOM node directly.
|
|
1074
|
-
const dead = rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid)));
|
|
1115
|
+
const dead = rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid), el.dataset.folderId));
|
|
1075
1116
|
if (dead && dead.el === el)
|
|
1076
1117
|
dead.detach();
|
|
1077
1118
|
else
|
|
@@ -1084,7 +1125,7 @@ export function removeMessagesAndReconcile(uids) {
|
|
|
1084
1125
|
}
|
|
1085
1126
|
if (outcome.focusedWasRemoved) {
|
|
1086
1127
|
const survivor = outcome.nextSurvivor;
|
|
1087
|
-
if (survivor && focusByIdentity(survivor.accountId, survivor.uid)) {
|
|
1128
|
+
if (survivor && focusByIdentity(survivor.accountId, survivor.uid, { folderId: survivor.folderId })) {
|
|
1088
1129
|
// focusByIdentity handled the transition (DOM class + viewer).
|
|
1089
1130
|
return;
|
|
1090
1131
|
}
|
|
@@ -1096,7 +1137,7 @@ export function removeMessagesAndReconcile(uids) {
|
|
|
1096
1137
|
// order; the first row that has a live DOM/rowByKey entry wins.
|
|
1097
1138
|
const remaining = state.getMessages();
|
|
1098
1139
|
for (const m of remaining) {
|
|
1099
|
-
if (focusByIdentity(m.accountId, m.uid))
|
|
1140
|
+
if (focusByIdentity(m.accountId, m.uid, { folderId: m.folderId }))
|
|
1100
1141
|
return;
|
|
1101
1142
|
}
|
|
1102
1143
|
// Truly empty folder (state.length === 0). Now releasing focus is
|
|
@@ -2011,7 +2052,7 @@ class MessageRow {
|
|
|
2011
2052
|
/** Insert this row into a list body and register it in the lookup map. */
|
|
2012
2053
|
attach(body) {
|
|
2013
2054
|
body.appendChild(this.el);
|
|
2014
|
-
rowByKey.set(rowKey(this.accountId, this.msg.uid), this);
|
|
2055
|
+
rowByKey.set(rowKey(this.accountId, this.msg.uid, this.msg.folderId), this);
|
|
2015
2056
|
}
|
|
2016
2057
|
/** Remove this row from the DOM and the lookup map. If it was the
|
|
2017
2058
|
* focused row, the controller is responsible for releasing focus
|
|
@@ -2019,7 +2060,7 @@ class MessageRow {
|
|
|
2019
2060
|
* may want to hand focus to a sibling instead). */
|
|
2020
2061
|
detach() {
|
|
2021
2062
|
this.el.remove();
|
|
2022
|
-
rowByKey.delete(rowKey(this.accountId, this.msg.uid));
|
|
2063
|
+
rowByKey.delete(rowKey(this.accountId, this.msg.uid, this.msg.folderId));
|
|
2023
2064
|
}
|
|
2024
2065
|
setSelected(yes) { this.el.classList.toggle("selected", yes); }
|
|
2025
2066
|
get isSelected() { return this.el.classList.contains("selected"); }
|
|
@@ -2560,7 +2601,7 @@ onEvent((ev) => {
|
|
|
2560
2601
|
return;
|
|
2561
2602
|
if (!ev.accountId || ev.previousDraftUid == null)
|
|
2562
2603
|
return;
|
|
2563
|
-
const row =
|
|
2604
|
+
const row = findRow(ev.accountId, Number(ev.previousDraftUid), ev.folderId);
|
|
2564
2605
|
if (row)
|
|
2565
2606
|
row.setDate(typeof ev.savedAt === "number" ? ev.savedAt : Date.now());
|
|
2566
2607
|
});
|