@bobfrankston/rmfmail 1.1.106 → 1.1.107

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
@@ -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 a real empty paragraph (not bare <br><br>) so every editor
1241
+ // has a proper block for the caret to land in and for the user's reply
1242
+ // to flow into. Bare <br>s aren't a block container — TinyMCE's caret
1243
+ // fell through to the quote and typing landed unpredictably (Bob
1244
+ // 2026-05-21). `<p><br></p>` is the standard "empty editable paragraph".
1245
+ return `<p><br></p><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><br></p><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,38 @@ 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;
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.
1954
+ if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }
1938
1955
 
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
1956
+ // "This folder" scope: instant client-side filter of the visible rows.
1957
+ // Only when the server checkbox is OFF — with it on we want the real
1958
+ // local+server search, not a row filter.
1959
+ if (localScope === "current" && !serverOn && !immediate) {
1942
1960
  const body = document.getElementById("ml-body");
1943
1961
  if (body) {
1944
1962
  const lower = query.toLowerCase();
@@ -1950,10 +1968,13 @@ function doSearch(immediate = false): void {
1950
1968
  return;
1951
1969
  }
1952
1970
 
1953
- loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId, includeTrash);
1971
+ // ── Phase 1: LOCAL search — always, immediately ──
1972
+ // The local SQLite store is fast; results paint as the user types.
1973
+ const localScopeEff = localScope === "current" ? "current" : "all";
1974
+ loadSearchResults(query, localScopeEff, currentAccountId, currentFolderId, includeTrash);
1954
1975
  setTitle(`${APP_NAME} - Search: ${query}`);
1955
1976
  setActiveTabView(
1956
- { kind: "search", query, scope: effectiveScope, accountId: currentAccountId, folderId: currentFolderId, includeTrash },
1977
+ { kind: "search", query, scope: serverOn ? "server" : localScopeEff, accountId: currentAccountId, folderId: currentFolderId, includeTrash },
1957
1978
  `Search: ${query}`,
1958
1979
  );
1959
1980
  // Record every executed search — Enter AND a settled debounced search
@@ -1961,6 +1982,18 @@ function doSearch(immediate = false): void {
1961
1982
  // keystroke progression, so a search the user typed without pressing
1962
1983
  // Enter ("Hoddie") still shows up in history.
1963
1984
  recordSearchHistory(query);
1985
+
1986
+ // ── Phase 2: SERVER search — deferred, augments phase 1 ──
1987
+ // Fires after the dally so a burst of keystrokes only triggers one IMAP
1988
+ // sweep. The local rows from phase 1 stay on screen the whole time
1989
+ // (loadSearchResults won't blank a list that already has rows), so the
1990
+ // server pass never makes the user stare at an empty list.
1991
+ if (serverOn) {
1992
+ serverSearchTimer = setTimeout(() => {
1993
+ serverSearchTimer = null;
1994
+ loadSearchResults(query, "server", currentAccountId, currentFolderId, includeTrash);
1995
+ }, SERVER_SEARCH_DALLY_MS);
1996
+ }
1964
1997
  }
1965
1998
 
1966
1999
  // Track current folder for scoped search
@@ -1970,6 +2003,7 @@ let reloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;
1970
2003
 
1971
2004
  searchInput?.addEventListener("input", () => {
1972
2005
  clearTimeout(searchTimeout);
2006
+ if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }
1973
2007
  updateSearchHighlight();
1974
2008
  if (searchInput.value.trim() === "") {
1975
2009
  // Cleared — reset immediately, no debounce. Must exit search mode
@@ -1981,7 +2015,10 @@ searchInput?.addEventListener("input", () => {
1981
2015
  reloadCurrentFolder();
1982
2016
  setTitle(APP_NAME);
1983
2017
  } else {
1984
- searchTimeout = setTimeout(() => doSearch(false), 300);
2018
+ // Short debounce just enough to coalesce a keystroke burst. The
2019
+ // local pass is cheap; the server pass has its own longer dally
2020
+ // inside doSearch, so this stays snappy.
2021
+ searchTimeout = setTimeout(() => doSearch(false), 180);
1985
2022
  }
1986
2023
  });
1987
2024
  searchInput?.addEventListener("keydown", (e) => {
@@ -1991,6 +2028,7 @@ searchInput?.addEventListener("keydown", (e) => {
1991
2028
  }
1992
2029
  if (e.key === "Escape") {
1993
2030
  searchInput.value = "";
2031
+ if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }
1994
2032
  updateSearchHighlight();
1995
2033
  clearSearchMode();
1996
2034
  // Clear any client-side filters
@@ -824,17 +824,33 @@ async function createTinyMceEditor(container2, opts = {}) {
824
824
  editor2.focus();
825
825
  },
826
826
  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)
827
+ const place = () => {
828
+ const body = editor2.getBody();
829
+ if (pos === 0) {
830
+ const first = body.firstChild;
831
+ if (first && first.nodeType === 1) {
832
+ editor2.selection.setCursorLocation(first, 0);
833
+ } else {
834
+ editor2.selection.select(body, true);
835
+ editor2.selection.collapse(true);
836
+ }
837
+ editor2.focus();
835
838
  editor2.getWin()?.scrollTo(0, 0);
836
- else
839
+ } else {
840
+ editor2.selection.select(body, true);
841
+ editor2.selection.collapse(false);
842
+ editor2.focus();
837
843
  editor2.selection.scrollIntoView();
844
+ }
845
+ };
846
+ try {
847
+ place();
848
+ editor2.getWin()?.requestAnimationFrame?.(() => {
849
+ try {
850
+ place();
851
+ } catch {
852
+ }
853
+ });
838
854
  } catch {
839
855
  }
840
856
  },