@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.
@@ -1559,43 +1559,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
1559
1559
  installPreviewControls(iframe);
1560
1560
  }
1561
1561
  else if (msg.bodyText) {
1562
- const pre = document.createElement("pre");
1563
- // Match the HTML-body branch's typography: sans-serif system
1564
- // font, same size + line-height. Default `<pre>` rendering is
1565
- // monospace, which on some platforms substitutes to a serif
1566
- // fallback (Bob 2026-05-12: "why the font change") and looks
1567
- // like a different message from the same conversation.
1568
- // height:100% + overflow:auto make the <pre> its own scroll
1569
- // container. Without them the <pre> grows to its full content
1570
- // height and the parent .mv-body (overflow:hidden) silently
1571
- // clips everything below the fold — no scrollbar, wheel dead
1572
- // (Bob 2026-05-22). The HTML-body branch gets this for free via
1573
- // the iframe; plain-text bodies need it explicitly.
1574
- pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word; "
1575
- + "font-family: system-ui, sans-serif; font-size: 17.5px; line-height: 1.5; "
1576
- + "color: #1a1a2e; background: #fff; margin: 0; height: 100%; overflow: auto;";
1577
- // Auto-linkify URLs in plain text.
1562
+ // Plain text renders through the SAME sandboxed iframe as an HTML
1563
+ // body, as a <pre> of linkified text.
1578
1564
  //
1579
- // For a big body this ONE STATEMENT is what makes the window look
1580
- // dead: linkifyText walks the whole string three times, then
1581
- // innerHTML parses megabytes of markup, then layout runs over tens
1582
- // of thousands of lines all synchronous on the main thread, so
1583
- // nothing repaints and WebView2 eventually reports the renderer as
1584
- // unresponsive (kind=2). It does finish, hence "oh just took
1585
- // forever" (Bob 2026-08-08).
1565
+ // It used to be a <pre> in the PARENT document, and that is why
1566
+ // "hovering over a URL or right mousing isn't working" in a
1567
+ // plain-text message (Bob 2026-08-09): every link affordance the
1568
+ // preview has hover target, the right-click link menu (Open /
1569
+ // Copy link address / search), tap-to-open-externally is
1570
+ // implemented for the iframe, by its injected script and its
1571
+ // contextmenu previewContextMenu bridge. A detected URL isn't
1572
+ // "less of a URL" than an <a> the sender wrote, so it goes through
1573
+ // the same path and behaves identically, instead of the parent
1574
+ // document's generic fallback menu.
1586
1575
  //
1587
- // A Web Worker can't help: workers have no DOM, and the expensive
1588
- // half IS the DOM. What works is refusing to do it in one gulp
1589
- // paint the first screenful immediately, then append the rest in
1590
- // line-aligned chunks across animation frames. Same content, same
1591
- // links, but the main thread comes up for air between chunks so
1592
- // the app stays live and the text streams in visibly.
1593
- if (msg.bodyText.length > PROGRESSIVE_TEXT_THRESHOLD) {
1594
- renderTextProgressively(pre, msg.bodyText, gen);
1595
- }
1596
- else {
1597
- pre.innerHTML = linkifyText(msg.bodyText);
1598
- }
1576
+ // Same reason as every other unification today: two renderers for
1577
+ // one job means every fix lands in one of them.
1599
1578
  // Oversized body: the service sent only the head, because the IPC
1600
1579
  // channel marshals replies through evaluate_script and a multi-MB
1601
1580
  // body takes the WebView2 renderer down with it. Say so — showing
@@ -1614,7 +1593,29 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
1614
1593
  <code style="user-select:all;font-size:0.9em">${escapeHtml(trunc.emlPath || "(path unavailable)")}</code></div>`;
1615
1594
  bodyEl.appendChild(note);
1616
1595
  }
1617
- bodyEl.appendChild(pre);
1596
+ const iframe = document.createElement("iframe");
1597
+ iframe.sandbox.add("allow-same-origin");
1598
+ iframe.sandbox.add("allow-popups");
1599
+ iframe.sandbox.add("allow-popups-to-escape-sandbox");
1600
+ iframe.sandbox.add("allow-top-navigation-by-user-activation");
1601
+ iframe.sandbox.add("allow-scripts");
1602
+ // `rmf-plain` is styled by wrapHtmlBody so the typography matches
1603
+ // what the parent <pre> used to produce.
1604
+ const head = msg.bodyText.length > PROGRESSIVE_TEXT_THRESHOLD
1605
+ ? msg.bodyText.slice(0, firstChunkEnd(msg.bodyText))
1606
+ : msg.bodyText;
1607
+ iframe.srcdoc = wrapHtmlBody(`<pre class="rmf-plain" id="rmf-plain">${linkifyText(head)}</pre>`, false);
1608
+ iframe.addEventListener("load", () => _ptick("iframe load (all resources)"), { once: true });
1609
+ bodyEl.appendChild(iframe);
1610
+ _ptick("iframe srcdoc set (plain text)");
1611
+ installPreviewControls(iframe);
1612
+ // Big body: stream the remainder into the iframe across animation
1613
+ // frames rather than parsing megabytes in one blocking gulp
1614
+ // (v1.2.229's reason, preserved — the work just happens inside the
1615
+ // iframe document now).
1616
+ if (msg.bodyText.length > head.length) {
1617
+ appendTextProgressively(iframe, msg.bodyText.slice(head.length), gen);
1618
+ }
1618
1619
  }
1619
1620
  else {
1620
1621
  // No bodyHtml AND no bodyText. The daemon thinks the body is
@@ -1968,47 +1969,73 @@ const PROGRESSIVE_TEXT_THRESHOLD = 200 * 1024;
1968
1969
  * 16 ms budget on a slow machine; large enough that a 1.4 MB body finishes in
1969
1970
  * a couple of dozen frames rather than hundreds. */
1970
1971
  const TEXT_CHUNK_BYTES = 64 * 1024;
1972
+ /** Where the first chunk of a large plain-text body ends — cut on a newline
1973
+ * so a URL is never split across chunks (half a URL linkifies wrong). */
1974
+ function firstChunkEnd(text) {
1975
+ const end = Math.min(TEXT_CHUNK_BYTES, text.length);
1976
+ if (end >= text.length)
1977
+ return text.length;
1978
+ const nl = text.lastIndexOf("\n", end);
1979
+ return nl > 0 ? nl + 1 : end;
1980
+ }
1971
1981
  /**
1972
- * Append `text` to `pre` in line-aligned chunks, one chunk per animation
1973
- * frame, linkifying each chunk as it goes.
1982
+ * Stream the rest of a large plain-text body into the preview iframe, one
1983
+ * line-aligned chunk per animation frame, linkifying each chunk as it goes.
1974
1984
  *
1975
- * Chunks are cut on newlines so a URL is never split across two chunks (which
1976
- * would linkify half of it). Each chunk goes in as its own inline <span>, so
1977
- * the parent's `white-space: pre-wrap` keeps the text flowing exactly as a
1978
- * single insert would.
1985
+ * Why chunked at all: linkify + HTML parse + layout over a multi-megabyte
1986
+ * body is synchronous, so doing it in one statement freezes the window long
1987
+ * enough for WebView2 to report the renderer unresponsive (Bob 2026-08-08,
1988
+ * "oh just took forever"). A Web Worker cannot help: workers have no DOM,
1989
+ * and the DOM work IS the expensive half. Coming up for air between chunks is
1990
+ * what keeps the app live, and the text visibly streams in.
1979
1991
  *
1980
- * Aborts if the user moves to another message mid-render `gen` is compared
1981
- * against showMessageGeneration, the same staleness guard the IPC path uses.
1992
+ * Aborts if the user moves to another message mid-render (`gen` against
1993
+ * showMessageGeneration, the same staleness guard the IPC path uses) or if
1994
+ * the iframe is torn out from under us.
1982
1995
  */
1983
- function renderTextProgressively(pre, text, gen) {
1996
+ function appendTextProgressively(iframe, rest, gen) {
1984
1997
  const chunks = [];
1985
1998
  let at = 0;
1986
- while (at < text.length) {
1987
- let end = Math.min(at + TEXT_CHUNK_BYTES, text.length);
1988
- if (end < text.length) {
1989
- const nl = text.lastIndexOf("\n", end);
1999
+ while (at < rest.length) {
2000
+ let end = Math.min(at + TEXT_CHUNK_BYTES, rest.length);
2001
+ if (end < rest.length) {
2002
+ const nl = rest.lastIndexOf("\n", end);
1990
2003
  if (nl > at)
1991
2004
  end = nl + 1;
1992
2005
  }
1993
- chunks.push(text.slice(at, end));
2006
+ chunks.push(rest.slice(at, end));
1994
2007
  at = end;
1995
2008
  }
1996
2009
  let i = 0;
1997
2010
  const step = () => {
1998
- // Stale — another message is on screen now; stop appending to a <pre>
1999
- // nobody is looking at.
2000
2011
  if (gen !== showMessageGeneration)
2001
- return;
2012
+ return; // another message is on screen
2013
+ const doc = iframe.contentDocument;
2014
+ const host = doc?.getElementById("rmf-plain");
2015
+ if (!doc || !host)
2016
+ return; // iframe replaced or not ready
2002
2017
  if (i >= chunks.length)
2003
2018
  return;
2004
- const span = document.createElement("span");
2019
+ const span = doc.createElement("span");
2005
2020
  span.innerHTML = linkifyText(chunks[i++]);
2006
- pre.appendChild(span);
2021
+ host.appendChild(span);
2007
2022
  requestAnimationFrame(step);
2008
2023
  };
2009
- // First chunk synchronously so the user sees text immediately rather than
2010
- // an empty pane for one frame.
2011
- step();
2024
+ // The iframe document may not exist for a frame or two after srcdoc is
2025
+ // set; poll briefly rather than dropping the tail on the floor.
2026
+ let waits = 0;
2027
+ const begin = () => {
2028
+ if (gen !== showMessageGeneration)
2029
+ return;
2030
+ if (iframe.contentDocument?.getElementById("rmf-plain")) {
2031
+ step();
2032
+ return;
2033
+ }
2034
+ if (waits++ > 120)
2035
+ return; // ~2 s at 60 fps — give up quietly
2036
+ requestAnimationFrame(begin);
2037
+ };
2038
+ requestAnimationFrame(begin);
2012
2039
  }
2013
2040
  function linkifyText(text) {
2014
2041
  // Escape HTML first
@@ -2180,6 +2207,19 @@ ${csp}
2180
2207
  img { max-width: 100%; }
2181
2208
  a { color: #1a6dd4; }
2182
2209
  pre, code { white-space: pre-wrap; }
2210
+ /* Plain-text bodies render as this <pre>. Sans-serif at the same size and
2211
+ line-height as an HTML body, so two messages in one thread don't look
2212
+ like they came from different apps (Bob 2026-05-12: "why the font
2213
+ change"). */
2214
+ .rmf-plain {
2215
+ margin: 0;
2216
+ padding: 0;
2217
+ font-family: system-ui, sans-serif;
2218
+ font-size: 17.5px;
2219
+ line-height: 1.5;
2220
+ white-space: pre-wrap;
2221
+ word-break: break-word;
2222
+ }
2183
2223
  blockquote { border-left: 3px solid #ccc; padding-left: 1rem; margin-left: 0; color: #666; }
2184
2224
  @media (prefers-color-scheme: dark) {
2185
2225
  body { color: #cdd6f4; background: #282840; }