@bobfrankston/rmfmail 1.1.106 → 1.1.108

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
@@ -7,7 +7,7 @@ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, rel
7
7
  import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
8
8
  import { initTabs, setActiveView as setActiveTabView, openTab } from "./components/tabs.js";
9
9
  import { getCurrentMessage, initViewer, popOutCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu, wrapHtmlBody } from "./components/message-viewer.js";
10
- import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore } from "./lib/api-client.js";
10
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore, cancelServerSearch } from "./lib/api-client.js";
11
11
  import * as messageState from "./lib/message-state.js";
12
12
  // ── New message badge (favicon + title) ──
13
13
  /** The user-visible app name. Single point of change for the rename;
@@ -1282,16 +1282,19 @@ function quoteBody(msg) {
1282
1282
  const date = new Date(msg.date).toLocaleString();
1283
1283
  const from = msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address;
1284
1284
  const body = sanitizeQuotedBody(msg);
1285
- // Two blank lines above the quote so the cursor lands with breathing room
1286
- // between the user's reply and the "On ... wrote:" line.
1287
- return `<br><br><div class="reply"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;
1285
+ // Lead with an empty paragraph so every editor has a real block for the
1286
+ // caret to land in and the user's reply to flow into bare <br>s aren't
1287
+ // a block container, so TinyMCE's caret fell through to the quote (Bob
1288
+ // 2026-05-21). The blank-line spacing lives in <br>s AFTER the </p>, not
1289
+ // inside it.
1290
+ return `<p></p><br><br><div class="reply"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;
1288
1291
  }
1289
1292
  function forwardBody(msg) {
1290
1293
  const date = new Date(msg.date).toLocaleString();
1291
1294
  const from = msg.from.name ? `${msg.from.name} &lt;${msg.from.address}&gt;` : msg.from.address;
1292
1295
  const to = msg.to.map((a) => a.name ? `${a.name} &lt;${a.address}&gt;` : a.address).join(", ");
1293
1296
  const body = sanitizeQuotedBody(msg);
1294
- return `<br><br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
1297
+ return `<p></p><br><br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
1295
1298
  }
1296
1299
  let lastDeleted = null;
1297
1300
  let lastMoved = null;
@@ -2013,6 +2016,14 @@ searchInput?.addEventListener("scroll", () => {
2013
2016
  });
2014
2017
  searchInput?.addEventListener("change", () => updateSearchHighlight());
2015
2018
  updateSearchHighlight();
2019
+ // Server search is slow (per-folder IMAP SEARCH across ~90 folders). It must
2020
+ // never gate the local result display, and it should only fire once typing
2021
+ // has settled. Local search runs immediately on every doSearch; the server
2022
+ // pass is scheduled `SERVER_SEARCH_DALLY_MS` later and any new keystroke
2023
+ // clears+reschedules it. message-list's loadGen counter discards a stale
2024
+ // server pass's results, so a restart is effectively an abort at the UI.
2025
+ const SERVER_SEARCH_DALLY_MS = 700;
2026
+ let serverSearchTimer = null;
2016
2027
  function doSearch(immediate = false) {
2017
2028
  const query = searchInput.value.trim();
2018
2029
  if (query.length === 0) {
@@ -2021,17 +2032,28 @@ function doSearch(immediate = false) {
2021
2032
  }
2022
2033
  if (query.length < 2 && !immediate)
2023
2034
  return;
2024
- // P20: orthogonal "Server" checkbox. When checked, scope switches to
2025
- // "server" which spans all folders on all accounts. Local scope dropdown
2026
- // is unchanged (all/current) for the local-only case.
2035
+ // P20: orthogonal "Server" checkbox. When checked, the search ALSO spans
2036
+ // every folder on the server but as a deferred second phase, never a
2037
+ // replacement for the instant local pass.
2027
2038
  const serverCheck = document.getElementById("search-server-too");
2028
2039
  const trashCheck = document.getElementById("search-include-trash");
2029
2040
  const localScope = searchScope?.value || "all";
2030
- const effectiveScope = serverCheck?.checked ? "server" : localScope;
2031
2041
  const includeTrash = !!trashCheck?.checked;
2032
- // "This folder" scope: instant client-side filter on debounce, server search on Enter
2033
- if (effectiveScope === "current" && !immediate) {
2034
- // Client-side filter of visible rows
2042
+ const serverOn = !!serverCheck?.checked;
2043
+ // Any re-run aborts a pending server pass — it'll be rescheduled below
2044
+ // if still wanted. This is the "editing the search aborts + restarts"
2045
+ // behavior: each keystroke cancels the prior server search. clearTimeout
2046
+ // kills a not-yet-fired pass; cancelServerSearch() aborts one that's
2047
+ // already mid-sweep on the daemon (generation bump → loop bails).
2048
+ if (serverSearchTimer) {
2049
+ clearTimeout(serverSearchTimer);
2050
+ serverSearchTimer = null;
2051
+ }
2052
+ cancelServerSearch();
2053
+ // "This folder" scope: instant client-side filter of the visible rows.
2054
+ // Only when the server checkbox is OFF — with it on we want the real
2055
+ // local+server search, not a row filter.
2056
+ if (localScope === "current" && !serverOn && !immediate) {
2035
2057
  const body = document.getElementById("ml-body");
2036
2058
  if (body) {
2037
2059
  const lower = query.toLowerCase();
@@ -2042,14 +2064,28 @@ function doSearch(immediate = false) {
2042
2064
  }
2043
2065
  return;
2044
2066
  }
2045
- loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId, includeTrash);
2067
+ // ── Phase 1: LOCAL search — always, immediately ──
2068
+ // The local SQLite store is fast; results paint as the user types.
2069
+ const localScopeEff = localScope === "current" ? "current" : "all";
2070
+ loadSearchResults(query, localScopeEff, currentAccountId, currentFolderId, includeTrash);
2046
2071
  setTitle(`${APP_NAME} - Search: ${query}`);
2047
- setActiveTabView({ kind: "search", query, scope: effectiveScope, accountId: currentAccountId, folderId: currentFolderId, includeTrash }, `Search: ${query}`);
2072
+ setActiveTabView({ kind: "search", query, scope: serverOn ? "server" : localScopeEff, accountId: currentAccountId, folderId: currentFolderId, includeTrash }, `Search: ${query}`);
2048
2073
  // Record every executed search — Enter AND a settled debounced search
2049
2074
  // alike. The prefix-pruning in recordSearchHistory() collapses the
2050
2075
  // keystroke progression, so a search the user typed without pressing
2051
2076
  // Enter ("Hoddie") still shows up in history.
2052
2077
  recordSearchHistory(query);
2078
+ // ── Phase 2: SERVER search — deferred, augments phase 1 ──
2079
+ // Fires after the dally so a burst of keystrokes only triggers one IMAP
2080
+ // sweep. The local rows from phase 1 stay on screen the whole time
2081
+ // (loadSearchResults won't blank a list that already has rows), so the
2082
+ // server pass never makes the user stare at an empty list.
2083
+ if (serverOn) {
2084
+ serverSearchTimer = setTimeout(() => {
2085
+ serverSearchTimer = null;
2086
+ loadSearchResults(query, "server", currentAccountId, currentFolderId, includeTrash);
2087
+ }, SERVER_SEARCH_DALLY_MS);
2088
+ }
2053
2089
  }
2054
2090
  // Track current folder for scoped search
2055
2091
  let currentAccountId = "";
@@ -2057,6 +2093,11 @@ let currentFolderId = 0;
2057
2093
  let reloadDebounceTimer = null;
2058
2094
  searchInput?.addEventListener("input", () => {
2059
2095
  clearTimeout(searchTimeout);
2096
+ if (serverSearchTimer) {
2097
+ clearTimeout(serverSearchTimer);
2098
+ serverSearchTimer = null;
2099
+ cancelServerSearch();
2100
+ }
2060
2101
  updateSearchHighlight();
2061
2102
  if (searchInput.value.trim() === "") {
2062
2103
  // Cleared — reset immediately, no debounce. Must exit search mode
@@ -2070,7 +2111,10 @@ searchInput?.addEventListener("input", () => {
2070
2111
  setTitle(APP_NAME);
2071
2112
  }
2072
2113
  else {
2073
- searchTimeout = setTimeout(() => doSearch(false), 300);
2114
+ // Short debounce just enough to coalesce a keystroke burst. The
2115
+ // local pass is cheap; the server pass has its own longer dally
2116
+ // inside doSearch, so this stays snappy.
2117
+ searchTimeout = setTimeout(() => doSearch(false), 180);
2074
2118
  }
2075
2119
  });
2076
2120
  searchInput?.addEventListener("keydown", (e) => {
@@ -2080,6 +2124,11 @@ searchInput?.addEventListener("keydown", (e) => {
2080
2124
  }
2081
2125
  if (e.key === "Escape") {
2082
2126
  searchInput.value = "";
2127
+ if (serverSearchTimer) {
2128
+ clearTimeout(serverSearchTimer);
2129
+ serverSearchTimer = null;
2130
+ }
2131
+ cancelServerSearch();
2083
2132
  updateSearchHighlight();
2084
2133
  clearSearchMode();
2085
2134
  // Clear any client-side filters
@@ -4481,6 +4530,17 @@ getSettings().then((s) => {
4481
4530
  }).catch(() => { });
4482
4531
  // Save editor choice to server settings
4483
4532
  function saveEditorSetting(editor) {
4533
+ // Update the localStorage cache SYNCHRONOUSLY. compose.ts reads
4534
+ // `mailx-editor-type` from localStorage at module-load to pick the
4535
+ // editor — its async getSettings() refresh only runs when a compose
4536
+ // window opens, and reads localStorage FIRST. Without this write the
4537
+ // cache stays stale and the next compose keeps the old editor until a
4538
+ // full app restart (Bob 2026-05-21: "changed to quill but got tinymce
4539
+ // until I restarted"). With it, the very next compose-open is correct.
4540
+ try {
4541
+ localStorage.setItem("mailx-editor-type", editor);
4542
+ }
4543
+ catch { /* private mode */ }
4484
4544
  getSettings().then((settings) => {
4485
4545
  settings.ui = { ...settings.ui, editor };
4486
4546
  saveSettings(settings);