@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.js CHANGED
@@ -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 a real empty paragraph (not bare <br><br>) so every editor
1286
+ // has a proper block for the caret to land in and for the user's reply
1287
+ // to flow into. Bare <br>s aren't a block container — TinyMCE's caret
1288
+ // fell through to the quote and typing landed unpredictably (Bob
1289
+ // 2026-05-21). `<p><br></p>` is the standard "empty editable paragraph".
1290
+ return `<p><br></p><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><br></p><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,25 @@ 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.
2046
+ if (serverSearchTimer) {
2047
+ clearTimeout(serverSearchTimer);
2048
+ serverSearchTimer = null;
2049
+ }
2050
+ // "This folder" scope: instant client-side filter of the visible rows.
2051
+ // Only when the server checkbox is OFF — with it on we want the real
2052
+ // local+server search, not a row filter.
2053
+ if (localScope === "current" && !serverOn && !immediate) {
2035
2054
  const body = document.getElementById("ml-body");
2036
2055
  if (body) {
2037
2056
  const lower = query.toLowerCase();
@@ -2042,14 +2061,28 @@ function doSearch(immediate = false) {
2042
2061
  }
2043
2062
  return;
2044
2063
  }
2045
- loadSearchResults(query, effectiveScope, currentAccountId, currentFolderId, includeTrash);
2064
+ // ── Phase 1: LOCAL search — always, immediately ──
2065
+ // The local SQLite store is fast; results paint as the user types.
2066
+ const localScopeEff = localScope === "current" ? "current" : "all";
2067
+ loadSearchResults(query, localScopeEff, currentAccountId, currentFolderId, includeTrash);
2046
2068
  setTitle(`${APP_NAME} - Search: ${query}`);
2047
- setActiveTabView({ kind: "search", query, scope: effectiveScope, accountId: currentAccountId, folderId: currentFolderId, includeTrash }, `Search: ${query}`);
2069
+ setActiveTabView({ kind: "search", query, scope: serverOn ? "server" : localScopeEff, accountId: currentAccountId, folderId: currentFolderId, includeTrash }, `Search: ${query}`);
2048
2070
  // Record every executed search — Enter AND a settled debounced search
2049
2071
  // alike. The prefix-pruning in recordSearchHistory() collapses the
2050
2072
  // keystroke progression, so a search the user typed without pressing
2051
2073
  // Enter ("Hoddie") still shows up in history.
2052
2074
  recordSearchHistory(query);
2075
+ // ── Phase 2: SERVER search — deferred, augments phase 1 ──
2076
+ // Fires after the dally so a burst of keystrokes only triggers one IMAP
2077
+ // sweep. The local rows from phase 1 stay on screen the whole time
2078
+ // (loadSearchResults won't blank a list that already has rows), so the
2079
+ // server pass never makes the user stare at an empty list.
2080
+ if (serverOn) {
2081
+ serverSearchTimer = setTimeout(() => {
2082
+ serverSearchTimer = null;
2083
+ loadSearchResults(query, "server", currentAccountId, currentFolderId, includeTrash);
2084
+ }, SERVER_SEARCH_DALLY_MS);
2085
+ }
2053
2086
  }
2054
2087
  // Track current folder for scoped search
2055
2088
  let currentAccountId = "";
@@ -2057,6 +2090,10 @@ let currentFolderId = 0;
2057
2090
  let reloadDebounceTimer = null;
2058
2091
  searchInput?.addEventListener("input", () => {
2059
2092
  clearTimeout(searchTimeout);
2093
+ if (serverSearchTimer) {
2094
+ clearTimeout(serverSearchTimer);
2095
+ serverSearchTimer = null;
2096
+ }
2060
2097
  updateSearchHighlight();
2061
2098
  if (searchInput.value.trim() === "") {
2062
2099
  // Cleared — reset immediately, no debounce. Must exit search mode
@@ -2070,7 +2107,10 @@ searchInput?.addEventListener("input", () => {
2070
2107
  setTitle(APP_NAME);
2071
2108
  }
2072
2109
  else {
2073
- searchTimeout = setTimeout(() => doSearch(false), 300);
2110
+ // Short debounce just enough to coalesce a keystroke burst. The
2111
+ // local pass is cheap; the server pass has its own longer dally
2112
+ // inside doSearch, so this stays snappy.
2113
+ searchTimeout = setTimeout(() => doSearch(false), 180);
2074
2114
  }
2075
2115
  });
2076
2116
  searchInput?.addEventListener("keydown", (e) => {
@@ -2080,6 +2120,10 @@ searchInput?.addEventListener("keydown", (e) => {
2080
2120
  }
2081
2121
  if (e.key === "Escape") {
2082
2122
  searchInput.value = "";
2123
+ if (serverSearchTimer) {
2124
+ clearTimeout(serverSearchTimer);
2125
+ serverSearchTimer = null;
2126
+ }
2083
2127
  updateSearchHighlight();
2084
2128
  clearSearchMode();
2085
2129
  // Clear any client-side filters