The mark is rebuilt as a lambda of two square-cut strokes of one weight, joined without a seam, on a flat ultramarine tile with continuous corners: the rounded strokes and the gradient are gone. A message written plainly is now drawn in the app's own colours instead of a white slab, with text too dark for the ground lifted and white backdrops cleared; a designed one (newsletter, receipt) keeps its white sheet. A reply's quoted history folds behind a button. A narrower reading column, a larger title, and a way to see a plain message as sent. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
205 lines
9.9 KiB
TypeScript
205 lines
9.9 KiB
TypeScript
/**
|
|
* Turns a message's sanitized HTML into something a frame can actually show.
|
|
*
|
|
* Two things were missing and both looked like "images are broken": a message
|
|
* written by any normal mail client references its own pictures as
|
|
* src="cid:...", which resolves against nothing in a browser; and the frame was
|
|
* handed a bare fragment with no document around it, so mail written for a
|
|
* white page rendered as dark text on a dark surface with no width limit.
|
|
*/
|
|
|
|
/** The parts the API returns alongside the body, keyed by Content-ID. */
|
|
export type InlineImages = Record<string, string>;
|
|
|
|
/**
|
|
* Replaces src="cid:x" with the data: URI the server delivered for that part.
|
|
*
|
|
* A reference with no matching part loses its source entirely rather than
|
|
* keeping a cid: the browser cannot fetch: an unresolvable src draws a
|
|
* broken-image icon, while no src at all falls back to the alt text.
|
|
*/
|
|
export function resolveInlineImages(html: string, images: InlineImages): string {
|
|
if (!html) return "";
|
|
|
|
return html.replace(
|
|
/(<img\b[^>]*?)\ssrc\s*=\s*("|')cid:([^"']*)\2/gi,
|
|
(_match, head: string, _quote: string, reference: string) => {
|
|
// Clients disagree about whether the reference keeps the angle brackets
|
|
// the Content-ID header carries, so both spellings resolve.
|
|
const key = decodeURIComponent(reference.trim()).replace(/^<|>$/g, "");
|
|
const resolved = images[key];
|
|
return resolved ? `${head} src="${resolved}"` : head;
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a message was designed, as a newsletter or a receipt is, rather than
|
|
* just written, as a letter between two people is.
|
|
*
|
|
* A designed message brings its own colours, tables and background images, and
|
|
* they only make sense on the white sheet it was drawn for. A written one has
|
|
* nothing of the kind and reads best when it takes the app's own colours - on a
|
|
* dark ground, a plain note as a bright white slab is the loudest thing on the
|
|
* screen and says nothing the words do not.
|
|
*
|
|
* Plain white is not design: Outlook paints it behind everything it sends.
|
|
*/
|
|
export function looksDesigned(html: string): boolean {
|
|
if (!html) return false;
|
|
const neutral = /^\s*(?:#f{3}(?:f{3})?|white|transparent|none|rgba?\(\s*255\s*,\s*255\s*,\s*255(?:\s*,\s*(?:1|1\.0|0?\.\d+))?\s*\)|rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\))\s*(?:!important)?\s*;?\s*$/i;
|
|
|
|
// bgcolor="#336699" and background="hero.png" are always a statement.
|
|
for (const m of html.matchAll(/\b(?:bgcolor|background)\s*=\s*(["']?)([^"'\s>]+)\1/gi)) {
|
|
if (!neutral.test(m[2])) return true;
|
|
}
|
|
// background / background-color in a style attribute or a <style> block.
|
|
for (const m of html.matchAll(/background(?:-color|-image)?\s*:\s*([^;}"']+)/gi)) {
|
|
const value = m[1].trim();
|
|
if (/url\(/i.test(value) || !neutral.test(value)) return true;
|
|
}
|
|
// A layout built from tables is a design even where nothing is coloured.
|
|
return (html.match(/<table\b/gi) ?? []).length >= 3;
|
|
}
|
|
|
|
/** The colours a message is drawn in, by the way it is shown. */
|
|
const SURFACES = {
|
|
paper: { fg: "#1d1d1f", muted: "#5b6272", link: "#3f4fe6", rule: "#d6d9e0", wash: "rgba(20,28,50,.05)", scheme: "light" },
|
|
dark: { fg: "#e6e9f0", muted: "#8e96a8", link: "#8794ff", rule: "#383e4d", wash: "rgba(233,237,245,.07)", scheme: "dark" },
|
|
light: { fg: "#12151c", muted: "#5b6272", link: "#3f4fe6", rule: "#cdd1db", wash: "rgba(20,28,50,.05)", scheme: "light" },
|
|
} as const;
|
|
|
|
export interface ReaderDocumentOptions {
|
|
/** The app's theme, which a message written plainly takes the colours of. */
|
|
theme?: "dark" | "light";
|
|
/**
|
|
* Draw the message on a white sheet, as it was written. A designed message
|
|
* always is; a plain one only when the reader asks to see it as sent.
|
|
*/
|
|
paper?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Wraps a message body in the document the reader frame renders.
|
|
*
|
|
* The frame is sandboxed with an opaque origin, so it cannot inherit anything
|
|
* from the app: every style, the surface mail assumes, the height report the
|
|
* host page sizes the frame from and the little script that tidies a message
|
|
* all have to travel inside it.
|
|
*/
|
|
export function buildReaderDocument(bodyHtml: string, options: ReaderDocumentOptions = {}): string {
|
|
const paper = options.paper ?? true;
|
|
const surface = paper ? SURFACES.paper : SURFACES[options.theme ?? "dark"];
|
|
const native = !paper;
|
|
return `<!doctype html>
|
|
<html><head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="${surface.scheme}">
|
|
<!-- Every link leaves this document: it has an opaque origin, so navigating in
|
|
place strands the reader on a blank pane with no way back. Declared here
|
|
rather than only in script so it holds even if script does not run. -->
|
|
<base target="_blank">
|
|
<!-- Any remote resource the reader opted into must not carry the message's URL
|
|
with it, and this holds without script. -->
|
|
<meta name="referrer" content="no-referrer">
|
|
<style>
|
|
:root { color-scheme:${surface.scheme}; }
|
|
html { background:${native ? "transparent" : "#ffffff"}; }
|
|
body {
|
|
margin:0; padding:${native ? "2px 0 6px" : "26px 30px"}; background:${native ? "transparent" : "#ffffff"}; color:${surface.fg};
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
font-size:${native ? "15.5px" : "15px"}; line-height:${native ? "1.68" : "1.55"};
|
|
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
|
/* Mail is written against desktop widths. Without these a newsletter
|
|
pushes a horizontal scrollbar across its own content. */
|
|
overflow-wrap:anywhere; word-break:break-word;
|
|
}
|
|
img, video, table, pre { max-width:100% !important; }
|
|
img { height:auto; }
|
|
table { border-collapse:collapse; }
|
|
/* A layout table keeps the width its author gave it - forcing "auto" is what
|
|
collapsed designed messages into a narrow column. max-width above is what
|
|
stops one from overflowing a small screen. */
|
|
p { margin:0 0 .9em; }
|
|
${native ? "img { border-radius:8px; }" : ""}
|
|
a { color:${surface.link}; text-decoration-thickness:1px; text-underline-offset:2px; }
|
|
pre, code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:.9em; }
|
|
pre { white-space:pre-wrap; background:${surface.wash}; padding:12px 14px; border-radius:8px; }
|
|
blockquote {
|
|
margin:.4em 0 .9em; padding:2px 0 2px 14px;
|
|
border-left:3px solid ${surface.rule}; color:${surface.muted};
|
|
}
|
|
hr { border:0; border-top:1px solid ${surface.rule}; margin:1.2em 0; }
|
|
/* The button that stands in for a reply's quoted history. */
|
|
.lm-quote-toggle {
|
|
display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 9px; margin:2px 0 8px;
|
|
border:0; border-radius:10px; background:${surface.wash}; color:${surface.muted};
|
|
font:600 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing:.08em; cursor:pointer;
|
|
}
|
|
.lm-quote-toggle:hover { color:${surface.fg}; }
|
|
.lm-quote-toggle:focus-visible { outline:2px solid ${surface.link}; outline-offset:2px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${bodyHtml}
|
|
<script>
|
|
// <base> already sends the click to a new tab; this adds the rel that keeps
|
|
// the destination from learning where the click came from.
|
|
for (const link of document.querySelectorAll("a[href]")) {
|
|
link.setAttribute("rel", "noopener noreferrer");
|
|
}
|
|
|
|
${native ? `// A message written plainly is drawn in the app's colours, but it may still
|
|
// carry its own: black text it was given by the program that wrote it, and a
|
|
// white backdrop behind every block. Dark text on a dark ground is the one
|
|
// thing this must never produce, so any colour too close to the ground goes.
|
|
(function () {
|
|
const dark = ${surface.scheme === "dark"};
|
|
const channels = (value) => (value.match(/[0-9.]+/g) || []).map(Number);
|
|
const luminance = ([r, g, b]) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
for (const el of document.body.querySelectorAll("*")) {
|
|
const style = getComputedStyle(el);
|
|
const [r, g, b, a = 1] = channels(style.backgroundColor);
|
|
if (a > 0 && r > 244 && g > 244 && b > 244) el.style.setProperty("background-color", "transparent", "important");
|
|
const [fr, fg, fb] = channels(style.color);
|
|
const lum = luminance([fr, fg, fb]);
|
|
if ((dark && lum < 0.42) || (!dark && lum > 0.62)) el.style.removeProperty("color"), el.removeAttribute("color"), el.style.setProperty("color", "inherit", "important");
|
|
}
|
|
})();` : ""}
|
|
|
|
// A reply carries the whole conversation under it. The history is one click
|
|
// away rather than a screenful in the way - and in a conversation, where each
|
|
// message is shown anyway, it is a duplicate.
|
|
(function () {
|
|
const quotes = [...document.querySelectorAll("blockquote")].filter((q) => !q.parentElement.closest("blockquote"));
|
|
for (const quote of quotes) {
|
|
if ((quote.textContent || "").trim().length < 60) continue;
|
|
const toggle = document.createElement("button");
|
|
toggle.type = "button";
|
|
toggle.className = "lm-quote-toggle";
|
|
toggle.textContent = "\u00b7\u00b7\u00b7";
|
|
toggle.setAttribute("aria-expanded", "false");
|
|
toggle.title = "...";
|
|
quote.style.display = "none";
|
|
toggle.addEventListener("click", () => {
|
|
const open = quote.style.display === "none";
|
|
quote.style.display = open ? "" : "none";
|
|
toggle.setAttribute("aria-expanded", String(open));
|
|
});
|
|
quote.parentNode.insertBefore(toggle, quote);
|
|
}
|
|
})();
|
|
|
|
// The host sizes the frame from this rather than guessing: a fixed height
|
|
// either clips a long message or leaves a slab of empty white under a short
|
|
// one, and a sandboxed frame cannot be measured from outside.
|
|
const report = () => parent.postMessage(
|
|
{ type: "lm:reader-height", height: document.documentElement.scrollHeight }, "*");
|
|
report();
|
|
window.addEventListener("load", report);
|
|
if (typeof ResizeObserver === "function") new ResizeObserver(report).observe(document.body);
|
|
</script>
|
|
</body></html>`;
|
|
}
|