@bobfrankston/rmfmail 1.0.607 → 1.0.619

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 CHANGED
@@ -9,8 +9,36 @@ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate,
9
9
  import * as messageState from "./lib/message-state.js";
10
10
  // ── New message badge (favicon + title) ──
11
11
  /** The user-visible app name. Single point of change for the rename;
12
- * every UI surface that shows "rmfmail" reads from here. */
12
+ * every UI surface that shows "rmfmail" reads from here. Static HTML
13
+ * uses placeholder text that `propagateAppName` (called immediately
14
+ * below) overwrites — so the constant is the only place the literal
15
+ * string lives. */
13
16
  export const APP_NAME = "rmfmail";
17
+ /** Stamp APP_NAME into every static HTML element that should show it.
18
+ * Runs synchronously at module load — before paint when imports are
19
+ * fast, with a sub-frame flash of the placeholder otherwise. Add new
20
+ * surfaces here, never inline literals. */
21
+ function propagateAppName() {
22
+ const set = (id, fn) => {
23
+ const el = document.getElementById(id);
24
+ if (el)
25
+ el.textContent = fn(APP_NAME);
26
+ };
27
+ document.title = APP_NAME;
28
+ set("startup-status", n => `Starting ${n}…`);
29
+ set("status-version", n => n);
30
+ set("app-version", n => n);
31
+ // About-button label / restart-button hover-titles live in HTML
32
+ // attributes; rewrite the ones that include the name.
33
+ const aboutBtn = document.getElementById("btn-about");
34
+ if (aboutBtn)
35
+ aboutBtn.title = `Show version and build info`;
36
+ const aboutText = aboutBtn?.textContent || "";
37
+ if (aboutBtn && aboutText.toLowerCase().includes("about")) {
38
+ aboutBtn.textContent = `About ${APP_NAME}...`;
39
+ }
40
+ }
41
+ propagateAppName();
14
42
  let baseTitle = APP_NAME;
15
43
  let lastSeenCount = 0;
16
44
  let badgeCount = 0;
@@ -894,10 +922,36 @@ async function deleteSelectedMessages() {
894
922
  selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
895
923
  }
896
924
  const statusSync = document.getElementById("status-sync");
925
+ // Optimistic UI: remove from list IMMEDIATELY, then queue the IPC.
926
+ // Old order awaited the daemon round-trip (IPC + DB updates) before
927
+ // the rows disappeared, which felt sluggish on bigger selections or
928
+ // when the IPC was congested. Spam button already worked this way;
929
+ // trash now matches. If the IPC fails, the next folder reload
930
+ // re-populates the row and the catch block surfaces the error.
931
+ const snapshot = [...selected];
932
+ removeMessagesAndReconcile(selected);
933
+ // Undo support set immediately too — Ctrl+Z works the moment rows
934
+ // disappear from the list, not only after the daemon ACKs.
935
+ if (snapshot.length === 1) {
936
+ lastDeleted = { ...snapshot[0], subject: "" };
937
+ if (statusSync)
938
+ statusSync.textContent = `Trashed 1 message (syncing) — Ctrl+Z to undo`;
939
+ }
940
+ else {
941
+ lastDeleted = null;
942
+ if (statusSync)
943
+ statusSync.textContent = `Trashed ${snapshot.length} messages (syncing)`;
944
+ }
945
+ if (undoTimeout)
946
+ clearTimeout(undoTimeout);
947
+ undoTimeout = setTimeout(() => {
948
+ lastDeleted = null;
949
+ if (statusSync?.textContent?.includes("undo"))
950
+ statusSync.textContent = "";
951
+ }, 30000);
897
952
  try {
898
- // Delete on server — group by account for bulk operations
899
953
  const byAccount = new Map();
900
- for (const msg of selected) {
954
+ for (const msg of snapshot) {
901
955
  const uids = byAccount.get(msg.accountId) || [];
902
956
  uids.push(msg.uid);
903
957
  byAccount.set(msg.accountId, uids);
@@ -905,32 +959,11 @@ async function deleteSelectedMessages() {
905
959
  for (const [accountId, uids] of byAccount) {
906
960
  await deleteMessages(accountId, uids);
907
961
  }
908
- // Undo supports the last batch
909
- if (selected.length === 1) {
910
- lastDeleted = { ...selected[0], subject: "" };
911
- if (statusSync)
912
- statusSync.textContent = `Trashed 1 message (syncing) — Ctrl+Z to undo`;
913
- }
914
- else {
915
- lastDeleted = null;
916
- if (statusSync)
917
- statusSync.textContent = `Trashed ${selected.length} messages (syncing)`;
918
- }
919
- if (undoTimeout)
920
- clearTimeout(undoTimeout);
921
- undoTimeout = setTimeout(() => {
922
- lastDeleted = null;
923
- if (statusSync?.textContent?.includes("undo"))
924
- statusSync.textContent = "";
925
- }, 30000);
926
- // Remove via the list controller — single transaction filters
927
- // state, deletes DOM rows, hands focus to a survivor (or clears
928
- // the viewer). Replaces the old "state.removeMessages broadcasts
929
- // a removed event, list+viewer subscribe independently" model.
930
- removeMessagesAndReconcile(selected);
931
962
  }
932
963
  catch (e) {
933
964
  console.error(`Delete failed: ${e.message}`);
965
+ if (statusSync)
966
+ statusSync.textContent = `Delete failed: ${e?.message || e}`;
934
967
  }
935
968
  }
936
969
  async function undoDelete() {
@@ -1790,7 +1823,7 @@ window.addEventListener("message", (e) => {
1790
1823
  // instantly and lingers when the user moves to do anything else,
1791
1824
  // including punching through the compose overlay (which sits at
1792
1825
  // z-index 1000 — popover was at 10000, hence the bug in the
1793
- // screenshot). Now: 500ms hover delay; suppressed entirely when
1826
+ // screenshot). Now: 1500ms hover delay; suppressed entirely when
1794
1827
  // any overlay (compose, modal) is open; auto-dismissed on click,
1795
1828
  // scroll, blur, or any keypress.
1796
1829
  const w = window;
@@ -1815,7 +1848,7 @@ window.addEventListener("message", (e) => {
1815
1848
  const source = e.source;
1816
1849
  w._linkHoverShowTimer = setTimeout(() => {
1817
1850
  // Re-check overlay state at fire time — overlay may have appeared
1818
- // during the 500ms wait.
1851
+ // during the 1500ms wait.
1819
1852
  if (document.querySelector(".compose-overlay, .mailx-modal-backdrop"))
1820
1853
  return;
1821
1854
  if (!pop) {
@@ -1850,7 +1883,7 @@ window.addEventListener("message", (e) => {
1850
1883
  pop.style.left = x + "px";
1851
1884
  pop.style.top = y + "px";
1852
1885
  }
1853
- }, 500);
1886
+ }, 1500);
1854
1887
  }
1855
1888
  if (e.data?.type === "previewKey" && typeof e.data.key === "string") {
1856
1889
  // Re-dispatch as a real keydown on document so the hotkey handler
@@ -2429,11 +2462,18 @@ document.addEventListener("keydown", (e) => {
2429
2462
  // Ctrl+D or Delete = Delete selected messages.
2430
2463
  // P15: don't hijack Delete inside text inputs / textareas / contenteditable
2431
2464
  // — JSONC editor's Delete key was being eaten because we always preventDefault'd.
2465
+ // 2026-05-09 fix: if the LIST has multiple selected rows, the user
2466
+ // clearly means "delete those" regardless of where focus is — search
2467
+ // input retaining focus after filtering shouldn't make the key dead.
2468
+ // Only defer to native behavior when there's no list selection AND
2469
+ // focus is in a text-editing surface.
2432
2470
  if ((e.ctrlKey && e.key === "d") || e.key === "Delete") {
2433
2471
  const t = e.target;
2434
2472
  const tag = t?.tagName;
2435
2473
  const editable = t?.isContentEditable;
2436
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable)
2474
+ const inEditable = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || editable;
2475
+ const listSelectionCount = document.querySelectorAll("#ml-body .ml-row.selected").length;
2476
+ if (inEditable && listSelectionCount === 0)
2437
2477
  return;
2438
2478
  e.preventDefault();
2439
2479
  deleteSelection();