@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.ts CHANGED
@@ -8,7 +8,7 @@ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, rel
8
8
  import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
9
9
  import { initTabs, setActiveView as setActiveTabView, openTab, type ViewTab } from "./components/tabs.js";
10
10
  import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu, wrapHtmlBody } from "./components/message-viewer.js";
11
- 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";
11
+ 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";
12
12
  import * as messageState from "./lib/message-state.js";
13
13
 
14
14
  // ── New message badge (favicon + title) ──
@@ -1237,9 +1237,12 @@ function quoteBody(msg: any): string {
1237
1237
  const date = new Date(msg.date).toLocaleString();
1238
1238
  const from = msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address;
1239
1239
  const body = sanitizeQuotedBody(msg);
1240
- // Two blank lines above the quote so the cursor lands with breathing room
1241
- // between the user's reply and the "On ... wrote:" line.
1242
- return `<br><br><div class="reply"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;
1240
+ // Lead with an empty paragraph so every editor has a real block for the
1241
+ // caret to land in and the user's reply to flow into bare <br>s aren't
1242
+ // a block container, so TinyMCE's caret fell through to the quote (Bob
1243
+ // 2026-05-21). The blank-line spacing lives in <br>s AFTER the </p>, not
1244
+ // inside it.
1245
+ return `<p></p><br><br><div class="reply"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;
1243
1246
  }
1244
1247
 
1245
1248
  function forwardBody(msg: any): string {
@@ -1247,7 +1250,7 @@ function forwardBody(msg: any): string {
1247
1250
  const from = msg.from.name ? `${msg.from.name} &lt;${msg.from.address}&gt;` : msg.from.address;
1248
1251
  const to = msg.to.map((a: any) => a.name ? `${a.name} &lt;${a.address}&gt;` : a.address).join(", ");
1249
1252
  const body = sanitizeQuotedBody(msg);
1250
- 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>`;
1253
+ 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>`;
1251
1254
  }
1252
1255
 
1253
1256
  // ── Delete with undo ──
@@ -1922,23 +1925,41 @@ searchInput?.addEventListener("scroll", () => {
1922
1925
  searchInput?.addEventListener("change", () => updateSearchHighlight());
1923
1926
  updateSearchHighlight();
1924
1927
 
1928
+ // Server search is slow (per-folder IMAP SEARCH across ~90 folders). It must
1929
+ // never gate the local result display, and it should only fire once typing
1930
+ // has settled. Local search runs immediately on every doSearch; the server
1931
+ // pass is scheduled `SERVER_SEARCH_DALLY_MS` later and any new keystroke
1932
+ // clears+reschedules it. message-list's loadGen counter discards a stale
1933
+ // server pass's results, so a restart is effectively an abort at the UI.
1934
+ const SERVER_SEARCH_DALLY_MS = 700;
1935
+ let serverSearchTimer: ReturnType<typeof setTimeout> | null = null;
1936
+
1925
1937
  function doSearch(immediate = false): void {
1926
1938
  const query = searchInput.value.trim();
1927
1939
  if (query.length === 0) { reloadCurrentFolder(); return; }
1928
1940
  if (query.length < 2 && !immediate) return;
1929
1941
 
1930
- // P20: orthogonal "Server" checkbox. When checked, scope switches to
1931
- // "server" which spans all folders on all accounts. Local scope dropdown
1932
- // is unchanged (all/current) for the local-only case.
1942
+ // P20: orthogonal "Server" checkbox. When checked, the search ALSO spans
1943
+ // every folder on the server but as a deferred second phase, never a
1944
+ // replacement for the instant local pass.
1933
1945
  const serverCheck = document.getElementById("search-server-too") as HTMLInputElement | null;
1934
1946
  const trashCheck = document.getElementById("search-include-trash") as HTMLInputElement | null;
1935
1947
  const localScope = searchScope?.value || "all";
1936
- const effectiveScope = serverCheck?.checked ? "server" : localScope;
1937
1948
  const includeTrash = !!trashCheck?.checked;
1938
-
1939
- // "This folder" scope: instant client-side filter on debounce, server search on Enter
1940
- if (effectiveScope === "current" && !immediate) {
1941
- // Client-side filter of visible rows
1949
+ const serverOn = !!serverCheck?.checked;
1950
+
1951
+ // Any re-run aborts a pending server pass — it'll be rescheduled below
1952
+ // if still wanted. This is the "editing the search aborts + restarts"
1953
+ // behavior: each keystroke cancels the prior server search. clearTimeout
1954
+ // kills a not-yet-fired pass; cancelServerSearch() aborts one that's
1955
+ // already mid-sweep on the daemon (generation bump → loop bails).
1956
+ if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }
1957
+ cancelServerSearch();
1958
+
1959
+ // "This folder" scope: instant client-side filter of the visible rows.
1960
+ // Only when the server checkbox is OFF — with it on we want the real
1961
+ // local+server search, not a row filter.
1962
+ if (localScope === "current" && !serverOn && !immediate) {
1942
1963
  const body = document.getElementById("ml-body");
1943
1964
  if (body) {
1944
1965
  const lower = query.toLowerCase();
@@ -1950,10 +1971,13 @@ function doSearch(immediate = false): void {
1950
1971
  return;
1951
1972
  }
1952
1973
 
1953
- loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId, includeTrash);
1974
+ // ── Phase 1: LOCAL search — always, immediately ──
1975
+ // The local SQLite store is fast; results paint as the user types.
1976
+ const localScopeEff = localScope === "current" ? "current" : "all";
1977
+ loadSearchResults(query, localScopeEff, currentAccountId, currentFolderId, includeTrash);
1954
1978
  setTitle(`${APP_NAME} - Search: ${query}`);
1955
1979
  setActiveTabView(
1956
- { kind: "search", query, scope: effectiveScope, accountId: currentAccountId, folderId: currentFolderId, includeTrash },
1980
+ { kind: "search", query, scope: serverOn ? "server" : localScopeEff, accountId: currentAccountId, folderId: currentFolderId, includeTrash },
1957
1981
  `Search: ${query}`,
1958
1982
  );
1959
1983
  // Record every executed search — Enter AND a settled debounced search
@@ -1961,6 +1985,18 @@ function doSearch(immediate = false): void {
1961
1985
  // keystroke progression, so a search the user typed without pressing
1962
1986
  // Enter ("Hoddie") still shows up in history.
1963
1987
  recordSearchHistory(query);
1988
+
1989
+ // ── Phase 2: SERVER search — deferred, augments phase 1 ──
1990
+ // Fires after the dally so a burst of keystrokes only triggers one IMAP
1991
+ // sweep. The local rows from phase 1 stay on screen the whole time
1992
+ // (loadSearchResults won't blank a list that already has rows), so the
1993
+ // server pass never makes the user stare at an empty list.
1994
+ if (serverOn) {
1995
+ serverSearchTimer = setTimeout(() => {
1996
+ serverSearchTimer = null;
1997
+ loadSearchResults(query, "server", currentAccountId, currentFolderId, includeTrash);
1998
+ }, SERVER_SEARCH_DALLY_MS);
1999
+ }
1964
2000
  }
1965
2001
 
1966
2002
  // Track current folder for scoped search
@@ -1970,6 +2006,7 @@ let reloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;
1970
2006
 
1971
2007
  searchInput?.addEventListener("input", () => {
1972
2008
  clearTimeout(searchTimeout);
2009
+ if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; cancelServerSearch(); }
1973
2010
  updateSearchHighlight();
1974
2011
  if (searchInput.value.trim() === "") {
1975
2012
  // Cleared — reset immediately, no debounce. Must exit search mode
@@ -1981,7 +2018,10 @@ searchInput?.addEventListener("input", () => {
1981
2018
  reloadCurrentFolder();
1982
2019
  setTitle(APP_NAME);
1983
2020
  } else {
1984
- searchTimeout = setTimeout(() => doSearch(false), 300);
2021
+ // Short debounce just enough to coalesce a keystroke burst. The
2022
+ // local pass is cheap; the server pass has its own longer dally
2023
+ // inside doSearch, so this stays snappy.
2024
+ searchTimeout = setTimeout(() => doSearch(false), 180);
1985
2025
  }
1986
2026
  });
1987
2027
  searchInput?.addEventListener("keydown", (e) => {
@@ -1991,6 +2031,8 @@ searchInput?.addEventListener("keydown", (e) => {
1991
2031
  }
1992
2032
  if (e.key === "Escape") {
1993
2033
  searchInput.value = "";
2034
+ if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }
2035
+ cancelServerSearch();
1994
2036
  updateSearchHighlight();
1995
2037
  clearSearchMode();
1996
2038
  // Clear any client-side filters
@@ -4181,6 +4223,14 @@ getSettings().then((s: any) => {
4181
4223
 
4182
4224
  // Save editor choice to server settings
4183
4225
  function saveEditorSetting(editor: string): void {
4226
+ // Update the localStorage cache SYNCHRONOUSLY. compose.ts reads
4227
+ // `mailx-editor-type` from localStorage at module-load to pick the
4228
+ // editor — its async getSettings() refresh only runs when a compose
4229
+ // window opens, and reads localStorage FIRST. Without this write the
4230
+ // cache stays stale and the next compose keeps the old editor until a
4231
+ // full app restart (Bob 2026-05-21: "changed to quill but got tinymce
4232
+ // until I restarted"). With it, the very next compose-open is correct.
4233
+ try { localStorage.setItem("mailx-editor-type", editor); } catch { /* private mode */ }
4184
4234
  getSettings().then((settings: any) => {
4185
4235
  settings.ui = { ...settings.ui, editor };
4186
4236
  saveSettings(settings);
@@ -44,6 +44,7 @@ __export(api_client_exports, {
44
44
  allowRemoteContent: () => allowRemoteContent,
45
45
  autocomplete: () => autocomplete,
46
46
  cancelQueuedOutgoing: () => cancelQueuedOutgoing,
47
+ cancelServerSearch: () => cancelServerSearch,
47
48
  closeWordEdit: () => closeWordEdit,
48
49
  connectEvents: () => connectEvents,
49
50
  connectWebSocket: () => connectWebSocket,
@@ -217,6 +218,9 @@ function getUnifiedInbox(page = 1, pageSize = 50) {
217
218
  function searchMessages(query, page = 1, pageSize = 50, scope = "all", accountId = "", folderId = 0, includeTrashSpam = false) {
218
219
  return ipc().searchMessages(query, page, pageSize, scope, accountId, folderId, includeTrashSpam);
219
220
  }
221
+ function cancelServerSearch() {
222
+ return ipc().cancelServerSearch?.();
223
+ }
220
224
  function getMessage(accountId, uid, allowRemote = false, folderId) {
221
225
  return ipc().getMessage(accountId, uid, allowRemote, folderId);
222
226
  }
@@ -824,17 +828,33 @@ async function createTinyMceEditor(container2, opts = {}) {
824
828
  editor2.focus();
825
829
  },
826
830
  setCursor(pos) {
827
- try {
828
- editor2.selection.select(editor2.getBody(), true);
829
- editor2.selection.collapse(
830
- pos === 0
831
- /* true = start */
832
- );
833
- editor2.focus();
834
- if (pos === 0)
831
+ const place = () => {
832
+ const body = editor2.getBody();
833
+ if (pos === 0) {
834
+ const first = body.firstChild;
835
+ if (first && first.nodeType === 1) {
836
+ editor2.selection.setCursorLocation(first, 0);
837
+ } else {
838
+ editor2.selection.select(body, true);
839
+ editor2.selection.collapse(true);
840
+ }
841
+ editor2.focus();
835
842
  editor2.getWin()?.scrollTo(0, 0);
836
- else
843
+ } else {
844
+ editor2.selection.select(body, true);
845
+ editor2.selection.collapse(false);
846
+ editor2.focus();
837
847
  editor2.selection.scrollIntoView();
848
+ }
849
+ };
850
+ try {
851
+ place();
852
+ editor2.getWin()?.requestAnimationFrame?.(() => {
853
+ try {
854
+ place();
855
+ } catch {
856
+ }
857
+ });
838
858
  } catch {
839
859
  }
840
860
  },