@bobfrankston/rmfmail 1.1.170 → 1.1.177
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/bin/mailx.js +140 -4
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +127 -5
- package/client/android-bootstrap.bundle.js +4 -3
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +91 -6
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +39 -0
- package/client/app.js.map +1 -1
- package/client/app.ts +33 -0
- package/client/components/message-list.js +114 -7
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +113 -6
- package/client/compose/compose.bundle.js +2068 -1775
- package/client/compose/compose.bundle.js.map +4 -4
- package/client/compose/editor.js +85 -0
- package/client/compose/editor.js.map +1 -1
- package/client/compose/editor.ts +79 -0
- package/client/compose/spellcheck-core.js +253 -0
- package/client/compose/spellcheck-core.js.map +1 -0
- package/client/compose/spellcheck-core.ts +242 -0
- package/package.json +5 -5
- package/packages/mailx-core/index.js +1 -1
- package/packages/mailx-core/index.js.map +1 -1
- package/packages/mailx-core/index.ts +1 -1
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +15 -1
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +9 -1
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-store/db.d.ts +9 -1
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +15 -2
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +18 -4
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.js +1 -1
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +1 -1
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
- package/packages/mailx-store-web/android-bootstrap.ts +1 -1
- package/packages/mailx-store-web/db.d.ts +2 -1
- package/packages/mailx-store-web/db.d.ts.map +1 -1
- package/packages/mailx-store-web/db.js +3 -2
- package/packages/mailx-store-web/db.js.map +1 -1
- package/packages/mailx-store-web/db.ts +4 -3
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/sync-manager.js +1 -1
- package/packages/mailx-store-web/sync-manager.js.map +1 -1
- package/packages/mailx-store-web/sync-manager.ts +1 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-46236 → node_modules.npmglobalize-stash-50992}/.package-lock.json +0 -0
|
@@ -109,6 +109,47 @@ function currentViewKey(): string | null {
|
|
|
109
109
|
const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
|
|
110
110
|
return cacheKey("folder", currentAccountId, currentFolderId, flaggedOnly);
|
|
111
111
|
}
|
|
112
|
+
/** Run `doRender` while preserving the visual position of the topmost
|
|
113
|
+
* visible row. If the user is scrolled past the top and new rows arrive
|
|
114
|
+
* above their current view (the IDLE-driven "new mail" case), the rows
|
|
115
|
+
* they were reading STAY at the same screen Y after render — no yank.
|
|
116
|
+
*
|
|
117
|
+
* Pattern: snapshot the topmost row's UUID + its current offset from the
|
|
118
|
+
* scroll-container top, do the render, then set scrollTop so that same
|
|
119
|
+
* row lands at the same offset. If the anchor row is gone (deleted),
|
|
120
|
+
* fall back to the existing scrollTop-by-pixels behavior. */
|
|
121
|
+
function withScrollAnchor(body: HTMLElement, doRender: () => void): void {
|
|
122
|
+
const scrollTop = body.scrollTop;
|
|
123
|
+
// If the user is at the very top, no anchor needed — let new rows
|
|
124
|
+
// appear naturally above and the scroll position stays at 0.
|
|
125
|
+
if (scrollTop < 4) {
|
|
126
|
+
doRender();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const rowsBefore = Array.from(body.querySelectorAll<HTMLElement>(".ml-row"));
|
|
130
|
+
let anchorUuid = "";
|
|
131
|
+
let anchorOffsetWithinViewport = 0;
|
|
132
|
+
for (const r of rowsBefore) {
|
|
133
|
+
const visualTop = r.offsetTop - scrollTop;
|
|
134
|
+
// The first row whose top is at or below the viewport top is the
|
|
135
|
+
// visible anchor. visualTop in [0, height) is "first fully or
|
|
136
|
+
// partially visible row from the top."
|
|
137
|
+
if (visualTop >= 0) {
|
|
138
|
+
anchorUuid = r.dataset.uuid || "";
|
|
139
|
+
anchorOffsetWithinViewport = visualTop;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
doRender();
|
|
144
|
+
if (!anchorUuid) return; // nothing to anchor to (empty list before render)
|
|
145
|
+
const after = body.querySelector<HTMLElement>(`.ml-row[data-uuid="${CSS.escape(anchorUuid)}"]`);
|
|
146
|
+
if (after) {
|
|
147
|
+
body.scrollTop = after.offsetTop - anchorOffsetWithinViewport;
|
|
148
|
+
}
|
|
149
|
+
// If the anchor row was deleted, leave scrollTop as the browser placed
|
|
150
|
+
// it after the innerHTML swap. Don't actively yank.
|
|
151
|
+
}
|
|
152
|
+
|
|
112
153
|
function rememberPosition(): void {
|
|
113
154
|
const key = currentViewKey();
|
|
114
155
|
if (!key) return;
|
|
@@ -294,9 +335,43 @@ export function setRowSeen(accountId: string, uid: number, yes: boolean): void {
|
|
|
294
335
|
}
|
|
295
336
|
|
|
296
337
|
/** Scroll the focused row into view. Wired to a keyboard shortcut so the
|
|
297
|
-
* user can recover after scrolling the list away from the preview.
|
|
338
|
+
* user can recover after scrolling the list away from the preview.
|
|
339
|
+
*
|
|
340
|
+
* Robust against partial re-renders: if focusedRow.el has been detached
|
|
341
|
+
* from the document (sync churned the row out, the list paginated, a
|
|
342
|
+
* filter changed), re-look-it-up by UID/account before giving up. Without
|
|
343
|
+
* this the original implementation would silently no-op against a stale
|
|
344
|
+
* DOM node — exactly the "Z does nothing" symptom from Bob 2026-05-27. */
|
|
298
345
|
export function scrollFocusedIntoView(): void {
|
|
299
|
-
if (focusedRow)
|
|
346
|
+
if (!focusedRow) return;
|
|
347
|
+
let el: HTMLElement | null = focusedRow.el;
|
|
348
|
+
if (!document.body.contains(el)) {
|
|
349
|
+
const body = document.getElementById("ml-body");
|
|
350
|
+
const uid = (focusedRow.msg as any)?.uid;
|
|
351
|
+
if (body && uid != null) {
|
|
352
|
+
el = body.querySelector<HTMLElement>(
|
|
353
|
+
`.ml-row[data-uid="${uid}"][data-account-id="${CSS.escape(focusedRow.accountId)}"]`
|
|
354
|
+
) || body.querySelector<HTMLElement>(`.ml-row[data-uid="${uid}"]`);
|
|
355
|
+
}
|
|
356
|
+
if (!el) {
|
|
357
|
+
// The row isn't in the loaded slice of the list. Tell the user
|
|
358
|
+
// visibly rather than silently no-op — they pressed a key, they
|
|
359
|
+
// deserve feedback.
|
|
360
|
+
const status = document.getElementById("status-sync");
|
|
361
|
+
if (status) {
|
|
362
|
+
status.textContent = "Selected message isn't in the loaded list — scroll down to load more, or open the folder it lives in.";
|
|
363
|
+
setTimeout(() => { if (status?.textContent?.startsWith("Selected message")) status.textContent = ""; }, 4000);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// Refresh the cached element pointer so subsequent calls find it.
|
|
368
|
+
// Re-apply the highlight in case it got stripped during re-render —
|
|
369
|
+
// this also addresses the "selected message isn't highlighted"
|
|
370
|
+
// observation in the same session.
|
|
371
|
+
focusedRow.el = el;
|
|
372
|
+
el.classList.add("selected");
|
|
373
|
+
}
|
|
374
|
+
el.scrollIntoView({ block: "center" });
|
|
300
375
|
}
|
|
301
376
|
|
|
302
377
|
/** Release the focus slot and clear the preview pane in one call. */
|
|
@@ -783,11 +858,18 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
783
858
|
}
|
|
784
859
|
|
|
785
860
|
state.setMessages(result.items);
|
|
786
|
-
|
|
861
|
+
// Anchor on the topmost visible row before re-rendering so new
|
|
862
|
+
// mail arriving above the user's current scroll position doesn't
|
|
863
|
+
// yank them. If they're at the very top, no anchor — new rows
|
|
864
|
+
// appear above as usual.
|
|
865
|
+
withScrollAnchor(body, () => renderMessages(body, "", result.items));
|
|
787
866
|
|
|
788
867
|
const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
|
|
789
868
|
if (targetUuid) {
|
|
790
|
-
|
|
869
|
+
// Only restore the saved scrollTop on a first paint (cache miss).
|
|
870
|
+
// After a sync-driven re-render, withScrollAnchor has already
|
|
871
|
+
// placed the body — overriding here would undo the anchor.
|
|
872
|
+
if (!cached) body.scrollTop = savedScroll;
|
|
791
873
|
restoreSelection(body, targetUuid);
|
|
792
874
|
} else if (autoSelect) {
|
|
793
875
|
selectFirst(body);
|
|
@@ -981,7 +1063,9 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
981
1063
|
}
|
|
982
1064
|
|
|
983
1065
|
state.setMessages(result.items);
|
|
984
|
-
|
|
1066
|
+
// Anchor on topmost visible row before render so a sync-driven
|
|
1067
|
+
// refresh doesn't yank the user's scroll position.
|
|
1068
|
+
withScrollAnchor(body, () => renderMessages(body, accountId, result.items));
|
|
985
1069
|
// Same fate-sharing rule for the non-empty branch: if the focused
|
|
986
1070
|
// viewer message isn't in the freshly-loaded set, clear it.
|
|
987
1071
|
if (focusedRow) {
|
|
@@ -998,7 +1082,11 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
998
1082
|
if (targetUuid) {
|
|
999
1083
|
requestAnimationFrame(() => {
|
|
1000
1084
|
if (myGen !== loadGen) return;
|
|
1001
|
-
|
|
1085
|
+
// On a first paint (no cache), use the saved scrollTop.
|
|
1086
|
+
// On a sync-driven re-render (cache existed), the scroll
|
|
1087
|
+
// anchor has already placed the body — overriding here
|
|
1088
|
+
// would undo the anchor and re-yank the user.
|
|
1089
|
+
if (!cached) body.scrollTop = savedScroll;
|
|
1002
1090
|
restoreSelection(body, targetUuid);
|
|
1003
1091
|
});
|
|
1004
1092
|
} else if (autoSelect) {
|
|
@@ -1043,6 +1131,25 @@ function renderMessages(body: HTMLElement, accountId: string, items: any[]): voi
|
|
|
1043
1131
|
appendMessages(tempDiv, accountId, items);
|
|
1044
1132
|
while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);
|
|
1045
1133
|
body.replaceChildren(fragment);
|
|
1134
|
+
// FATE-SHARING (central rule): viewer state is a byproduct of list
|
|
1135
|
+
// selection. If the previously-focused row isn't in the freshly-
|
|
1136
|
+
// rendered set, the viewer MUST clear — otherwise the user sees a
|
|
1137
|
+
// preview of a message that no longer appears in the list, which is
|
|
1138
|
+
// the "preview but empty summary" bug. Doing this in renderMessages
|
|
1139
|
+
// (not at the per-caller site) covers loadMessages, loadUnifiedInbox,
|
|
1140
|
+
// loadSearchResults, reloadCurrentFolder, and every other code path
|
|
1141
|
+
// that funnels through here. Previous per-caller fate-sharing existed
|
|
1142
|
+
// only in loadMessages — the gap was real. */
|
|
1143
|
+
if (focusedRow) {
|
|
1144
|
+
const fAcct = focusedRow.accountId;
|
|
1145
|
+
const fUid = (focusedRow.msg as any)?.uid;
|
|
1146
|
+
const stillThere = items.some((m: any) =>
|
|
1147
|
+
(m.accountId || accountId) === fAcct && m.uid === fUid);
|
|
1148
|
+
if (!stillThere) {
|
|
1149
|
+
viewerClear();
|
|
1150
|
+
focusedRow = null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1046
1153
|
}
|
|
1047
1154
|
|
|
1048
1155
|
function selectFirst(body: HTMLElement): void {
|