@bobfrankston/rmfmail 1.0.658 → 1.0.660

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/TODO.md CHANGED
@@ -236,6 +236,7 @@ Previously shown as showstoppers; moved here because they haven't recurred on re
236
236
 
237
237
  Small, self-contained items. Pick them up between higher-priority blocks without asking. Bump version per fix.
238
238
 
239
+ - **Q134 — Real popout: compose + preview into independent msger windows.** Current popout button (title-bar icon) toggles the floating compose overlay to fill the host window — fast win but not a true OS window. Bob 2026-05-10: "popout should pop out into an independent window. This should also be true for the preview." Requires daemon refactor: single `handle = showService(...)` in `bin/mailx.ts` (~40 `handle.send` call sites) becomes a `windows` registry — `broadcast(msg)` fans events to all open windows; per-window onRequest closures route request acks back to the originator. New jsonrpc actions `openPopout({ mode, init })` (spawns a fresh `showService` with `contentDir: <client>` pointed at `compose/compose.html` or a `preview/preview.html` for viewer popout) and `getPopoutInit({ token })` (init data delivery — see option below). Client wires the popout button to call `api.openPopout(...)` then closes the floating overlay. Preview gets a matching button in `message-viewer.ts`. Init data delivery: URL-hash token + first-load fetch from daemon cache; popout has no state until it asks. Estimate: half-day. Pre-req for any window-pair workflow (browse main + read popped-out message, write compose while triaging inbox).
239
240
  - **Q133 — TinyMCE: pre-warm CDN fetch / progressive editor swap.** Bob 2026-05-10: "if fetching tinymce takes time it should be done in background and only activate when it is available." Today, picking TinyMCE in Settings means the FIRST compose-open after that setting blocks for ~1s while the CDN bundle downloads. Better: when user toggles to TinyMCE in Settings, fire a fetch (or a `<link rel="modulepreload">`) so the bundle is in browser cache by the time they hit Compose. Even better: open compose with Quill instantly, swap the editor to TinyMCE in place once the bundle resolves. Touches `compose.ts` editor init + `app.ts` Settings toggle handler.
240
241
  - **Q132 — importgen bugs blocking "always use importgen" rule for rmfmail.** Three issues, fix in `y:\dev\utils\importgen`: (1) reads CWD's `package.json`, so running from `client/` reads `client/package.json` (declares only quill); needs a `-p` flag or to walk up. (2) `resolveEntryPoint` blows up on packages with conditional `exports` (e.g. `".": { "import": { "node": "...", "browser": "..." } }`) — `dotExport.import` becomes an object, then `entryPoint.startsWith` throws. (3) Doesn't follow dynamic `await import()` calls if it scans imports rather than package.json deps (still need to verify which path it actually takes). Until fixed, rmfmail's import map is hand-maintained.
241
242
  - **Q131 — Show holidays in calendar sidebar.** Thunderbird-style "Show holidays" checkbox. When checked, daemon's `getCalendarEvents` ALSO fetches `en.usa#holiday@group.v.calendar.google.com` (Google's standard US holidays) and merges into results, marking each with `isHoliday: true`. UI renders holiday rows with a distinct style (muted color, no edit/delete actions). Concrete edits: (a) `mailx-service/index.ts` line 1075ish `getCalendarEvents` — also call `gsync.listCalendarEvents(tp, fromMs, toMs, "en.usa#holiday@group.v.calendar.google.com")` when settings.calendar.showHolidays is true, store with `is_holiday=1` flag; (b) `mailx-store/db.ts` `calendar_events` schema — add `is_holiday` column; (c) `client/components/calendar-sidebar.ts` — render rows with `data-holiday="1"` styled distinctly; (d) settings UI radio/checkbox in the calendar sidebar's options popover. ~2 hours.
package/client/app.js CHANGED
@@ -794,12 +794,68 @@ function showComposeOverlay(title = "Compose") {
794
794
  wrapper.style.cssText = "position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;background:#fff;";
795
795
  }
796
796
  else {
797
- wrapper.style.cssText = "position:fixed;bottom:0;right:16px;width:min(900px,55vw);height:min(700px,70vh);z-index:1000;border-radius:8px 8px 0 0;box-shadow:0 -4px 24px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;";
797
+ // CSS `resize:both` only gives a single lower-right grip. To allow
798
+ // dragging any edge or corner, we omit it here and attach eight
799
+ // manual handles via addComposeResizeHandles() below.
800
+ wrapper.style.cssText = "position:fixed;bottom:0;right:16px;width:min(900px,55vw);height:min(700px,70vh);z-index:1000;border-radius:8px 8px 0 0;box-shadow:0 -4px 24px rgba(0,0,0,0.3);display:flex;flex-direction:column;overflow:hidden;";
798
801
  }
799
- // Title bar — drag to move, close button
802
+ // Title bar — drag to move; right-side cluster holds discard, popout, close.
800
803
  const titleBar = document.createElement("div");
801
804
  titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#e8ecf0;border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;";
802
- titleBar.textContent = title;
805
+ const titleText = document.createElement("span");
806
+ titleText.textContent = title;
807
+ titleBar.appendChild(titleText);
808
+ const btnCluster = document.createElement("div");
809
+ btnCluster.style.cssText = "display:flex;align-items:center;gap:2px;";
810
+ titleBar.appendChild(btnCluster);
811
+ // Outline SVG icons matched to the rest of the toolbar — 16px, 1.6 stroke,
812
+ // currentColor so hover styles work via plain CSS.
813
+ const SVG_ATTRS = `width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"`;
814
+ const TRASH_SVG = `<svg ${SVG_ATTRS}><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
815
+ const POPOUT_SVG = `<svg ${SVG_ATTRS}><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>`;
816
+ const RESTORE_SVG = `<svg ${SVG_ATTRS}><path d="M21 9v-6h-6"/><path d="M3 15v6h6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>`;
817
+ const makeIconBtn = (svg, titleStr, hoverColor) => {
818
+ const b = document.createElement("button");
819
+ b.title = titleStr;
820
+ b.style.cssText = "background:none;border:none;cursor:pointer;color:#666;padding:4px 6px;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;line-height:0;";
821
+ b.innerHTML = svg;
822
+ b.addEventListener("mouseenter", () => b.style.color = hoverColor);
823
+ b.addEventListener("mouseleave", () => b.style.color = "#666");
824
+ return b;
825
+ };
826
+ const discardBtn = makeIconBtn(TRASH_SVG, "Discard draft", "#c00");
827
+ discardBtn.addEventListener("click", () => {
828
+ try {
829
+ const win = frame.contentWindow;
830
+ if (win)
831
+ win.dispatchEvent(new Event("compose-discard"));
832
+ }
833
+ catch { /* */ }
834
+ });
835
+ btnCluster.appendChild(discardBtn);
836
+ // Popout = toggle between the floating overlay and a host-window-filling
837
+ // layout. The compose iframe can't open in a real OS window (msger custom
838
+ // protocol doesn't propagate to child windows), so "popout" here means
839
+ // "fill the host window" — second click restores the floating geometry.
840
+ const popoutBtn = makeIconBtn(POPOUT_SVG, "Maximize", "#000");
841
+ let maximized = false;
842
+ let savedCss = "";
843
+ popoutBtn.addEventListener("click", () => {
844
+ if (!maximized) {
845
+ savedCss = wrapper.style.cssText;
846
+ wrapper.style.cssText = "position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;background:#fff;overflow:hidden;";
847
+ popoutBtn.innerHTML = RESTORE_SVG;
848
+ popoutBtn.title = "Restore";
849
+ maximized = true;
850
+ }
851
+ else {
852
+ wrapper.style.cssText = savedCss;
853
+ popoutBtn.innerHTML = POPOUT_SVG;
854
+ popoutBtn.title = "Maximize";
855
+ maximized = false;
856
+ }
857
+ });
858
+ btnCluster.appendChild(popoutBtn);
803
859
  const closeBtn = document.createElement("button");
804
860
  closeBtn.textContent = "✕";
805
861
  closeBtn.title = "Save draft and close";
@@ -818,7 +874,7 @@ function showComposeOverlay(title = "Compose") {
818
874
  }
819
875
  catch { /* */ }
820
876
  });
821
- titleBar.appendChild(closeBtn);
877
+ btnCluster.appendChild(closeBtn);
822
878
  // Drag to move. While dragging we set pointer-events:none on the iframe
823
879
  // so mouse events don't get swallowed by the inner document the moment
824
880
  // the cursor crosses into the iframe region. Without that, drag only
@@ -876,13 +932,86 @@ function showComposeOverlay(title = "Compose") {
876
932
  });
877
933
  wrapper.appendChild(titleBar);
878
934
  wrapper.appendChild(frame);
935
+ if (!isSmall)
936
+ addComposeResizeHandles(wrapper, frame);
879
937
  document.body.appendChild(wrapper);
880
938
  }
939
+ /** Attach eight resize grippers (4 edges + 4 corners) to a positioned wrapper.
940
+ * CSS `resize:both` only supports a single lower-right corner; replacing it
941
+ * with manual handles is the only way to make any side / corner draggable.
942
+ * Each handle captures the starting geometry on mousedown, then mousemove
943
+ * adjusts width/height/left/top in the directions implied by which edge. */
944
+ function addComposeResizeHandles(wrapper, frame) {
945
+ const MIN_W = 320;
946
+ const MIN_H = 200;
947
+ const dirs = {
948
+ n: { cursor: "ns-resize", style: "top:-3px;left:12px;right:12px;height:6px;" },
949
+ s: { cursor: "ns-resize", style: "bottom:-3px;left:12px;right:12px;height:6px;" },
950
+ e: { cursor: "ew-resize", style: "right:-3px;top:12px;bottom:12px;width:6px;" },
951
+ w: { cursor: "ew-resize", style: "left:-3px;top:12px;bottom:12px;width:6px;" },
952
+ ne: { cursor: "nesw-resize", style: "top:-3px;right:-3px;width:14px;height:14px;" },
953
+ nw: { cursor: "nwse-resize", style: "top:-3px;left:-3px;width:14px;height:14px;" },
954
+ se: { cursor: "nwse-resize", style: "bottom:-3px;right:-3px;width:14px;height:14px;" },
955
+ sw: { cursor: "nesw-resize", style: "bottom:-3px;left:-3px;width:14px;height:14px;" },
956
+ };
957
+ for (const [dir, conf] of Object.entries(dirs)) {
958
+ const h = document.createElement("div");
959
+ h.style.cssText = `position:absolute;z-index:2;background:transparent;cursor:${conf.cursor};${conf.style}`;
960
+ h.addEventListener("mousedown", (e) => {
961
+ e.preventDefault();
962
+ e.stopPropagation();
963
+ const rect = wrapper.getBoundingClientRect();
964
+ const startX = e.clientX, startY = e.clientY;
965
+ const startL = rect.left, startT = rect.top;
966
+ const startW = rect.width, startH = rect.height;
967
+ // Pin to top-left so left/top can be adjusted to grow upward/leftward.
968
+ wrapper.style.left = `${startL}px`;
969
+ wrapper.style.top = `${startT}px`;
970
+ wrapper.style.right = "auto";
971
+ wrapper.style.bottom = "auto";
972
+ frame.style.pointerEvents = "none";
973
+ document.body.style.userSelect = "none";
974
+ const onMove = (ev) => {
975
+ const dx = ev.clientX - startX;
976
+ const dy = ev.clientY - startY;
977
+ let newW = startW, newH = startH, newL = startL, newT = startT;
978
+ if (dir.includes("e"))
979
+ newW = Math.max(MIN_W, startW + dx);
980
+ if (dir.includes("w")) {
981
+ newW = Math.max(MIN_W, startW - dx);
982
+ newL = startL + (startW - newW);
983
+ }
984
+ if (dir.includes("s"))
985
+ newH = Math.max(MIN_H, startH + dy);
986
+ if (dir.includes("n")) {
987
+ newH = Math.max(MIN_H, startH - dy);
988
+ newT = startT + (startH - newH);
989
+ }
990
+ wrapper.style.width = `${newW}px`;
991
+ wrapper.style.height = `${newH}px`;
992
+ wrapper.style.left = `${newL}px`;
993
+ wrapper.style.top = `${newT}px`;
994
+ };
995
+ const onUp = () => {
996
+ frame.style.pointerEvents = "";
997
+ document.body.style.userSelect = "";
998
+ document.removeEventListener("mousemove", onMove);
999
+ document.removeEventListener("mouseup", onUp);
1000
+ };
1001
+ document.addEventListener("mousemove", onMove);
1002
+ document.addEventListener("mouseup", onUp);
1003
+ });
1004
+ wrapper.appendChild(h);
1005
+ }
1006
+ }
881
1007
  // Marketing-email layout tables (deeply nested, fixed widths) collapse to
882
1008
  // 30-40px columns inside a phone-width compose pane and wrap text
883
1009
  // character-by-character. Strip styles + flatten tables before quoting.
884
1010
  function sanitizeQuotedBody(msg) {
885
- let body = msg.bodyHtml || `<pre>${msg.bodyText || ""}</pre>`;
1011
+ // `white-space:pre-wrap` preserves the original line breaks but lets long
1012
+ // lines wrap to the compose width. `<pre>` would have suppressed wrapping
1013
+ // entirely, producing a horizontal-scrolling quote inside the editor.
1014
+ let body = msg.bodyHtml || `<div style="white-space:pre-wrap;font-family:inherit;margin:0">${msg.bodyText || ""}</div>`;
886
1015
  body = body.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
887
1016
  body = body.replace(/\s+style="[^"]*"/gi, "");
888
1017
  body = body.replace(/\s+class="[^"]*"/gi, "");