@bobfrankston/rmfmail 1.2.237 → 1.2.239

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.
@@ -1435,42 +1435,22 @@ export async function showMessage(accountId: string, uid: number, folderId?: num
1435
1435
  _ptick("iframe srcdoc set");
1436
1436
  installPreviewControls(iframe);
1437
1437
  } else if (msg.bodyText) {
1438
- const pre = document.createElement("pre");
1439
- // Match the HTML-body branch's typography: sans-serif system
1440
- // font, same size + line-height. Default `<pre>` rendering is
1441
- // monospace, which on some platforms substitutes to a serif
1442
- // fallback (Bob 2026-05-12: "why the font change") and looks
1443
- // like a different message from the same conversation.
1444
- // height:100% + overflow:auto make the <pre> its own scroll
1445
- // container. Without them the <pre> grows to its full content
1446
- // height and the parent .mv-body (overflow:hidden) silently
1447
- // clips everything below the fold — no scrollbar, wheel dead
1448
- // (Bob 2026-05-22). The HTML-body branch gets this for free via
1449
- // the iframe; plain-text bodies need it explicitly.
1450
- pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word; "
1451
- + "font-family: system-ui, sans-serif; font-size: 17.5px; line-height: 1.5; "
1452
- + "color: #1a1a2e; background: #fff; margin: 0; height: 100%; overflow: auto;";
1453
- // Auto-linkify URLs in plain text.
1438
+ // Plain text renders through the SAME sandboxed iframe as an HTML
1439
+ // body, as a <pre> of linkified text.
1454
1440
  //
1455
- // For a big body this ONE STATEMENT is what makes the window look
1456
- // dead: linkifyText walks the whole string three times, then
1457
- // innerHTML parses megabytes of markup, then layout runs over tens
1458
- // of thousands of lines all synchronous on the main thread, so
1459
- // nothing repaints and WebView2 eventually reports the renderer as
1460
- // unresponsive (kind=2). It does finish, hence "oh just took
1461
- // forever" (Bob 2026-08-08).
1441
+ // It used to be a <pre> in the PARENT document, and that is why
1442
+ // "hovering over a URL or right mousing isn't working" in a
1443
+ // plain-text message (Bob 2026-08-09): every link affordance the
1444
+ // preview has hover target, the right-click link menu (Open /
1445
+ // Copy link address / search), tap-to-open-externally is
1446
+ // implemented for the iframe, by its injected script and its
1447
+ // contextmenu previewContextMenu bridge. A detected URL isn't
1448
+ // "less of a URL" than an <a> the sender wrote, so it goes through
1449
+ // the same path and behaves identically, instead of the parent
1450
+ // document's generic fallback menu.
1462
1451
  //
1463
- // A Web Worker can't help: workers have no DOM, and the expensive
1464
- // half IS the DOM. What works is refusing to do it in one gulp
1465
- // paint the first screenful immediately, then append the rest in
1466
- // line-aligned chunks across animation frames. Same content, same
1467
- // links, but the main thread comes up for air between chunks so
1468
- // the app stays live and the text streams in visibly.
1469
- if (msg.bodyText.length > PROGRESSIVE_TEXT_THRESHOLD) {
1470
- renderTextProgressively(pre, msg.bodyText, gen);
1471
- } else {
1472
- pre.innerHTML = linkifyText(msg.bodyText);
1473
- }
1452
+ // Same reason as every other unification today: two renderers for
1453
+ // one job means every fix lands in one of them.
1474
1454
  // Oversized body: the service sent only the head, because the IPC
1475
1455
  // channel marshals replies through evaluate_script and a multi-MB
1476
1456
  // body takes the WebView2 renderer down with it. Say so — showing
@@ -1489,7 +1469,30 @@ export async function showMessage(accountId: string, uid: number, folderId?: num
1489
1469
  <code style="user-select:all;font-size:0.9em">${escapeHtml(trunc.emlPath || "(path unavailable)")}</code></div>`;
1490
1470
  bodyEl.appendChild(note);
1491
1471
  }
1492
- bodyEl.appendChild(pre);
1472
+
1473
+ const iframe = document.createElement("iframe");
1474
+ iframe.sandbox.add("allow-same-origin");
1475
+ iframe.sandbox.add("allow-popups");
1476
+ iframe.sandbox.add("allow-popups-to-escape-sandbox");
1477
+ iframe.sandbox.add("allow-top-navigation-by-user-activation");
1478
+ iframe.sandbox.add("allow-scripts");
1479
+ // `rmf-plain` is styled by wrapHtmlBody so the typography matches
1480
+ // what the parent <pre> used to produce.
1481
+ const head = msg.bodyText.length > PROGRESSIVE_TEXT_THRESHOLD
1482
+ ? msg.bodyText.slice(0, firstChunkEnd(msg.bodyText))
1483
+ : msg.bodyText;
1484
+ iframe.srcdoc = wrapHtmlBody(`<pre class="rmf-plain" id="rmf-plain">${linkifyText(head)}</pre>`, false);
1485
+ iframe.addEventListener("load", () => _ptick("iframe load (all resources)"), { once: true });
1486
+ bodyEl.appendChild(iframe);
1487
+ _ptick("iframe srcdoc set (plain text)");
1488
+ installPreviewControls(iframe);
1489
+ // Big body: stream the remainder into the iframe across animation
1490
+ // frames rather than parsing megabytes in one blocking gulp
1491
+ // (v1.2.229's reason, preserved — the work just happens inside the
1492
+ // iframe document now).
1493
+ if (msg.bodyText.length > head.length) {
1494
+ appendTextProgressively(iframe, msg.bodyText.slice(head.length), gen);
1495
+ }
1493
1496
  } else {
1494
1497
  // No bodyHtml AND no bodyText. The daemon thinks the body is
1495
1498
  // on disk (`cached === true`) but extracted nothing. Most
@@ -1814,44 +1817,64 @@ const PROGRESSIVE_TEXT_THRESHOLD = 200 * 1024;
1814
1817
  * a couple of dozen frames rather than hundreds. */
1815
1818
  const TEXT_CHUNK_BYTES = 64 * 1024;
1816
1819
 
1820
+ /** Where the first chunk of a large plain-text body ends — cut on a newline
1821
+ * so a URL is never split across chunks (half a URL linkifies wrong). */
1822
+ function firstChunkEnd(text: string): number {
1823
+ const end = Math.min(TEXT_CHUNK_BYTES, text.length);
1824
+ if (end >= text.length) return text.length;
1825
+ const nl = text.lastIndexOf("\n", end);
1826
+ return nl > 0 ? nl + 1 : end;
1827
+ }
1828
+
1817
1829
  /**
1818
- * Append `text` to `pre` in line-aligned chunks, one chunk per animation
1819
- * frame, linkifying each chunk as it goes.
1830
+ * Stream the rest of a large plain-text body into the preview iframe, one
1831
+ * line-aligned chunk per animation frame, linkifying each chunk as it goes.
1820
1832
  *
1821
- * Chunks are cut on newlines so a URL is never split across two chunks (which
1822
- * would linkify half of it). Each chunk goes in as its own inline <span>, so
1823
- * the parent's `white-space: pre-wrap` keeps the text flowing exactly as a
1824
- * single insert would.
1833
+ * Why chunked at all: linkify + HTML parse + layout over a multi-megabyte
1834
+ * body is synchronous, so doing it in one statement freezes the window long
1835
+ * enough for WebView2 to report the renderer unresponsive (Bob 2026-08-08,
1836
+ * "oh just took forever"). A Web Worker cannot help: workers have no DOM,
1837
+ * and the DOM work IS the expensive half. Coming up for air between chunks is
1838
+ * what keeps the app live, and the text visibly streams in.
1825
1839
  *
1826
- * Aborts if the user moves to another message mid-render `gen` is compared
1827
- * against showMessageGeneration, the same staleness guard the IPC path uses.
1840
+ * Aborts if the user moves to another message mid-render (`gen` against
1841
+ * showMessageGeneration, the same staleness guard the IPC path uses) or if
1842
+ * the iframe is torn out from under us.
1828
1843
  */
1829
- function renderTextProgressively(pre: HTMLElement, text: string, gen: number): void {
1844
+ function appendTextProgressively(iframe: HTMLIFrameElement, rest: string, gen: number): void {
1830
1845
  const chunks: string[] = [];
1831
1846
  let at = 0;
1832
- while (at < text.length) {
1833
- let end = Math.min(at + TEXT_CHUNK_BYTES, text.length);
1834
- if (end < text.length) {
1835
- const nl = text.lastIndexOf("\n", end);
1847
+ while (at < rest.length) {
1848
+ let end = Math.min(at + TEXT_CHUNK_BYTES, rest.length);
1849
+ if (end < rest.length) {
1850
+ const nl = rest.lastIndexOf("\n", end);
1836
1851
  if (nl > at) end = nl + 1;
1837
1852
  }
1838
- chunks.push(text.slice(at, end));
1853
+ chunks.push(rest.slice(at, end));
1839
1854
  at = end;
1840
1855
  }
1841
1856
  let i = 0;
1842
1857
  const step = (): void => {
1843
- // Stale another message is on screen now; stop appending to a <pre>
1844
- // nobody is looking at.
1845
- if (gen !== showMessageGeneration) return;
1858
+ if (gen !== showMessageGeneration) return; // another message is on screen
1859
+ const doc = iframe.contentDocument;
1860
+ const host = doc?.getElementById("rmf-plain");
1861
+ if (!doc || !host) return; // iframe replaced or not ready
1846
1862
  if (i >= chunks.length) return;
1847
- const span = document.createElement("span");
1863
+ const span = doc.createElement("span");
1848
1864
  span.innerHTML = linkifyText(chunks[i++]);
1849
- pre.appendChild(span);
1865
+ host.appendChild(span);
1850
1866
  requestAnimationFrame(step);
1851
1867
  };
1852
- // First chunk synchronously so the user sees text immediately rather than
1853
- // an empty pane for one frame.
1854
- step();
1868
+ // The iframe document may not exist for a frame or two after srcdoc is
1869
+ // set; poll briefly rather than dropping the tail on the floor.
1870
+ let waits = 0;
1871
+ const begin = (): void => {
1872
+ if (gen !== showMessageGeneration) return;
1873
+ if (iframe.contentDocument?.getElementById("rmf-plain")) { step(); return; }
1874
+ if (waits++ > 120) return; // ~2 s at 60 fps — give up quietly
1875
+ requestAnimationFrame(begin);
1876
+ };
1877
+ requestAnimationFrame(begin);
1855
1878
  }
1856
1879
 
1857
1880
  function linkifyText(text: string): string {
@@ -2020,6 +2043,19 @@ ${csp}
2020
2043
  img { max-width: 100%; }
2021
2044
  a { color: #1a6dd4; }
2022
2045
  pre, code { white-space: pre-wrap; }
2046
+ /* Plain-text bodies render as this <pre>. Sans-serif at the same size and
2047
+ line-height as an HTML body, so two messages in one thread don't look
2048
+ like they came from different apps (Bob 2026-05-12: "why the font
2049
+ change"). */
2050
+ .rmf-plain {
2051
+ margin: 0;
2052
+ padding: 0;
2053
+ font-family: system-ui, sans-serif;
2054
+ font-size: 17.5px;
2055
+ line-height: 1.5;
2056
+ white-space: pre-wrap;
2057
+ word-break: break-word;
2058
+ }
2023
2059
  blockquote { border-left: 3px solid #ccc; padding-left: 1rem; margin-left: 0; color: #666; }
2024
2060
  @media (prefers-color-scheme: dark) {
2025
2061
  body { color: #cdd6f4; background: #282840; }
@@ -6775,12 +6775,12 @@ function showExternalEditHint(editorLabel) {
6775
6775
  onEvent((ev) => {
6776
6776
  if (ev?.type !== "wordEditUpdated") return;
6777
6777
  if (!wordEditId || ev.editId !== wordEditId) {
6778
- console.log(`[word-edit] ignoring save for ${ev.editId} \u2014 this window is ${wordEditId || "(no session)"}`);
6778
+ logClientEvent("word-edit-ignored", { forEditId: ev.editId, thisWindow: wordEditId || null });
6779
6779
  return;
6780
6780
  }
6781
6781
  try {
6782
6782
  extEditHintClose?.();
6783
- console.log(`[word-edit] applying mirrored save: ${(ev.html || "").length} chars (editId ${ev.editId})`);
6783
+ logClientEvent("word-edit-applied", { chars: (ev.html || "").length, editId: ev.editId });
6784
6784
  editor.setHtml(ev.html || "");
6785
6785
  showDraftStatus("Reloaded edits from external editor.", false);
6786
6786
  scheduleDraftSave();