@bobfrankston/rmfmail 1.0.484 → 1.0.486

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
@@ -3,7 +3,7 @@
3
3
  * Wires together all UI components and WebSocket connection.
4
4
  */
5
5
  import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from "./components/folder-tree.js";
6
- import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached, getCurrentFocused, releaseFocus, removeMessagesAndReconcile } from "./components/message-list.js";
6
+ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached, getCurrentFocused, releaseFocus, removeMessagesAndReconcile, setRowFlagged, scrollFocusedIntoView } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage } from "./components/message-viewer.js";
8
8
  import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage } from "./lib/api-client.js";
9
9
  import * as messageState from "./lib/message-state.js";
@@ -479,19 +479,10 @@ document.addEventListener("pointerdown", (e) => {
479
479
  if (window.innerWidth <= 768)
480
480
  rail.classList.remove("open");
481
481
  }, true);
482
- // Tapping any rail button dismisses the drawer afterwardthe user picked
483
- // a destination, the drawer's job is done. Skipping the menus that anchor
484
- // off the rail (settings/view) so the menu has time to open before the
485
- // rail collapses out from under it.
486
- document.querySelectorAll(".icon-rail .rail-btn").forEach(btn => {
487
- btn.addEventListener("click", () => {
488
- if (window.innerWidth > 768)
489
- return;
490
- if (btn.id === "rail-settings" || btn.id === "rail-view")
491
- return;
492
- document.querySelector(".icon-rail")?.classList.remove("open");
493
- });
494
- });
482
+ // The rail stays open until the user clicks outside it handled by the
483
+ // document-level outside-click handler above. No per-button close: chaining
484
+ // rail actions (toggle theme, then check About, then change a setting)
485
+ // without having to re-open the rail each time was the explicit ask.
495
486
  // ── Toolbar actions ──
496
487
  document.getElementById("btn-sync")?.addEventListener("click", async () => {
497
488
  const btn = document.getElementById("btn-sync");
@@ -1000,14 +991,9 @@ document.getElementById("btn-flag")?.addEventListener("click", async () => {
1000
991
  await updateFlags(sel.accountId, sel.uid, newFlags);
1001
992
  sel.flags = newFlags;
1002
993
  messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
1003
- // Update the message-list row's flag indicator
1004
- const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`);
1005
- if (row) {
1006
- row.classList.toggle("flagged", newFlags.includes("\\Flagged"));
1007
- const flagEl = row.querySelector(".ml-flag");
1008
- if (flagEl)
1009
- flagEl.textContent = newFlags.includes("\\Flagged") ? "\u2605" : "\u2606";
1010
- }
994
+ // Row owns its own DOM \u2014 go through the row object so class + star
995
+ // update atomically and the list/preview stay in sync.
996
+ setRowFlagged(sel.accountId, sel.uid, newFlags.includes("\\Flagged"));
1011
997
  }
1012
998
  catch (e) {
1013
999
  console.error(`Flag toggle failed: ${e.message}`);
@@ -2160,6 +2146,36 @@ document.addEventListener("keydown", (e) => {
2160
2146
  row.classList.toggle("unread", !newFlags.includes("\\Seen"));
2161
2147
  }).catch(() => { });
2162
2148
  }
2149
+ // Z = locate the focused row in the list (scroll-to-selected). After
2150
+ // scrolling the list out of sync with the preview, this snaps back.
2151
+ if (e.key.toLowerCase() === "z" && !e.ctrlKey && !e.metaKey && !e.altKey) {
2152
+ const active = document.activeElement;
2153
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
2154
+ return;
2155
+ const editable = active?.isContentEditable;
2156
+ if (editable)
2157
+ return;
2158
+ e.preventDefault();
2159
+ scrollFocusedIntoView();
2160
+ }
2161
+ // F6 / Shift+F6 — standard pane-switch shortcut. Cycles focus among
2162
+ // folder tree → message list → message viewer.
2163
+ if (e.key === "F6") {
2164
+ e.preventDefault();
2165
+ cyclePaneFocus(e.shiftKey);
2166
+ }
2167
+ // ? = show keyboard shortcuts help. Skip when typing in inputs so a
2168
+ // literal "?" in search/compose doesn't pop the dialog.
2169
+ if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) {
2170
+ const active = document.activeElement;
2171
+ if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
2172
+ return;
2173
+ const editable = active?.isContentEditable;
2174
+ if (editable)
2175
+ return;
2176
+ e.preventDefault();
2177
+ openShortcutsDialog();
2178
+ }
2163
2179
  // Arrow keys + Home/End/PgUp/PgDn — navigate message list (Q58).
2164
2180
  if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(e.key)) {
2165
2181
  const active = document.activeElement;
@@ -2193,6 +2209,96 @@ document.addEventListener("keydown", (e) => {
2193
2209
  }
2194
2210
  }
2195
2211
  });
2212
+ // ── F6 pane cycling ──
2213
+ function cyclePaneFocus(reverse) {
2214
+ // Major panes in tab order. Skip ones not currently visible (folder
2215
+ // tree is hidden in narrow tier when the rail/drawer is closed; message
2216
+ // viewer is only meaningful with a message open).
2217
+ const panes = [
2218
+ { id: "folder-tree", el: document.getElementById("folder-tree") },
2219
+ { id: "ml-body", el: document.getElementById("ml-body") },
2220
+ { id: "message-viewer", el: document.getElementById("message-viewer") },
2221
+ ].filter(p => {
2222
+ if (!p.el)
2223
+ return false;
2224
+ const r = p.el.getBoundingClientRect();
2225
+ return r.width > 0 && r.height > 0;
2226
+ });
2227
+ if (panes.length === 0)
2228
+ return;
2229
+ const active = document.activeElement;
2230
+ const currentIdx = panes.findIndex(p => p.el && (p.el === active || p.el.contains(active)));
2231
+ const step = reverse ? -1 : 1;
2232
+ const nextIdx = currentIdx < 0 ? 0 : (currentIdx + step + panes.length) % panes.length;
2233
+ const next = panes[nextIdx];
2234
+ if (!next.el)
2235
+ return;
2236
+ // Make the pane focusable if it isn't already; tabindex=-1 lets us
2237
+ // focus it programmatically without inserting it into Tab order.
2238
+ if (!next.el.hasAttribute("tabindex"))
2239
+ next.el.setAttribute("tabindex", "-1");
2240
+ next.el.focus();
2241
+ // Visual hint — brief outline so the user can see which pane took focus.
2242
+ next.el.style.outline = "2px solid var(--color-accent, #3b82f6)";
2243
+ next.el.style.outlineOffset = "-2px";
2244
+ setTimeout(() => { next.el.style.outline = ""; next.el.style.outlineOffset = ""; }, 600);
2245
+ }
2246
+ // ── Keyboard shortcuts help dialog ──
2247
+ function openShortcutsDialog() {
2248
+ if (document.querySelector(".mailx-shortcuts-modal"))
2249
+ return;
2250
+ const backdrop = document.createElement("div");
2251
+ backdrop.className = "mailx-modal-backdrop mailx-shortcuts-modal";
2252
+ const panel = document.createElement("div");
2253
+ panel.className = "mailx-modal";
2254
+ panel.style.maxWidth = "560px";
2255
+ panel.innerHTML = `
2256
+ <div class="mailx-modal-title">
2257
+ <span class="mailx-modal-title-text">Keyboard shortcuts</span>
2258
+ <button type="button" class="mailx-modal-close" id="sc-x" title="Close (Esc)" aria-label="Close">&times;</button>
2259
+ </div>
2260
+ <div class="mailx-about">
2261
+ <dl class="mailx-about-dl">
2262
+ <dt>Compose</dt><dd>Ctrl+N</dd>
2263
+ <dt>Reply</dt><dd>Ctrl+R</dd>
2264
+ <dt>Reply all</dt><dd>Ctrl+Shift+R</dd>
2265
+ <dt>Forward</dt><dd>Ctrl+F</dd>
2266
+ <dt>Send (in compose)</dt><dd>Ctrl+Enter</dd>
2267
+ <dt>Sync</dt><dd>F5</dd>
2268
+ <dt>Delete selected</dt><dd>Del or Ctrl+D</dd>
2269
+ <dt>Undo last delete/move</dt><dd>Ctrl+Z</dd>
2270
+ <dt>Toggle read/unread</dt><dd>R</dd>
2271
+ <dt>Toggle flag</dt><dd>(Flag button in viewer)</dd>
2272
+ <dt>Select all visible</dt><dd>Ctrl+A</dd>
2273
+ <dt>Navigate list</dt><dd>↑ ↓ Home End PgUp PgDn</dd>
2274
+ <dt>Scroll-to-focused row</dt><dd>Z</dd>
2275
+ <dt>Switch pane</dt><dd>F6 / Shift+F6</dd>
2276
+ <dt>Find / search</dt><dd>(focus the search box)</dd>
2277
+ <dt>Close dialog</dt><dd>Esc</dd>
2278
+ <dt>Show this help</dt><dd>?</dd>
2279
+ </dl>
2280
+ <div class="mailx-about-foot">On Android most shortcuts need a Bluetooth/USB keyboard. Touch equivalents: tap a row to view, tap the rail icons for navigation/settings/theme/help, long-press a row for the context menu.</div>
2281
+ </div>
2282
+ <div class="mailx-modal-buttons">
2283
+ <span class="mailx-modal-spacer"></span>
2284
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="close">Close</button>
2285
+ </div>`;
2286
+ backdrop.appendChild(panel);
2287
+ document.body.appendChild(backdrop);
2288
+ const close = () => { backdrop.remove(); document.removeEventListener("keydown", onKey, true); };
2289
+ const onKey = (e) => {
2290
+ if (e.key === "Escape") {
2291
+ e.stopPropagation();
2292
+ e.preventDefault();
2293
+ close();
2294
+ }
2295
+ };
2296
+ document.addEventListener("keydown", onKey, true);
2297
+ panel.querySelector('[data-action="close"]').addEventListener("click", close);
2298
+ panel.querySelector("#sc-x").addEventListener("click", close);
2299
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
2300
+ close(); });
2301
+ }
2196
2302
  // ── View menu ──
2197
2303
  const viewBtn = document.getElementById("btn-view");
2198
2304
  const viewDropdown = document.getElementById("view-dropdown");
@@ -2817,6 +2923,13 @@ function renderMarkdown(md) {
2817
2923
  closeList();
2818
2924
  return out.join("\n");
2819
2925
  }
2926
+ // ── Keyboard shortcuts (Settings menu item) ──
2927
+ document.getElementById("btn-shortcuts")?.addEventListener("click", () => {
2928
+ const settingsDropdown = document.getElementById("settings-dropdown");
2929
+ if (settingsDropdown)
2930
+ settingsDropdown.hidden = true;
2931
+ openShortcutsDialog();
2932
+ });
2820
2933
  // ── About dialog ──
2821
2934
  document.getElementById("btn-about")?.addEventListener("click", () => {
2822
2935
  const settingsDropdown = document.getElementById("settings-dropdown");