Merge feat/viewer-and-logo: a drawn logo and a reader that suits both themes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -586,5 +586,9 @@
|
||||
"hintClose": "close",
|
||||
"open": "Command menu",
|
||||
"signOut": "Sign out"
|
||||
},
|
||||
"viewer": {
|
||||
"asSent": "View as sent",
|
||||
"adapt": "Match the theme"
|
||||
}
|
||||
}
|
||||
@@ -586,5 +586,9 @@
|
||||
"hintClose": "cerrar",
|
||||
"open": "Menú de comandos",
|
||||
"signOut": "Cerrar sesión"
|
||||
},
|
||||
"viewer": {
|
||||
"asSent": "Ver como se envió",
|
||||
"adapt": "Adaptar al tema"
|
||||
}
|
||||
}
|
||||
@@ -586,5 +586,9 @@
|
||||
"hintClose": "fechar",
|
||||
"open": "Menu de comandos",
|
||||
"signOut": "Sair"
|
||||
},
|
||||
"viewer": {
|
||||
"asSent": "Ver como enviado",
|
||||
"adapt": "Adaptar ao tema"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 7.2 KiB |
@@ -30,6 +30,7 @@ import {
|
||||
} from "../../../../../lib/mailCounts";
|
||||
import { plainSnippet } from "../../../../../lib/snippet";
|
||||
import { useTrustedSenders } from "../../../../../lib/trustedSenders";
|
||||
import { useTheme } from "../../../../../lib/theme";
|
||||
import type { InlineImages } from "../../../../../lib/emailBody";
|
||||
import { messageDocument, hasBlockedRemoteContent } from "../../../../../lib/renderMessage";
|
||||
import {
|
||||
@@ -158,6 +159,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
|
||||
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||
// A plainly written message takes the app's colours; this shows it as sent.
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
// The open conversation, when the list is showing conversations.
|
||||
const [selectedThread, setSelectedThread] = useState<MessageSummary | null>(null);
|
||||
const conversations = useConversationView();
|
||||
@@ -444,6 +448,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
}
|
||||
}
|
||||
setLoadRemoteImages(false);
|
||||
setShowOriginal(false);
|
||||
setReaderHeight(420);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
||||
@@ -478,13 +483,13 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
* markup, then resolve the message's own inline parts, then block or restore
|
||||
* remote sources - which is the only part the reader chooses.
|
||||
*/
|
||||
const readerDocument = useMemo(() => {
|
||||
const readerBody = useMemo(() => {
|
||||
if (!selected) return null;
|
||||
// A trusted sender's images load without asking; anyone else's wait for
|
||||
// the reader's choice, one message at a time.
|
||||
const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? "");
|
||||
return messageDocument(selected, showImages);
|
||||
}, [selected, loadRemoteImages, trustedSenders]);
|
||||
return messageDocument(selected, showImages, { theme, original: showOriginal });
|
||||
}, [selected, loadRemoteImages, trustedSenders, theme, showOriginal]);
|
||||
|
||||
// Whether anything was actually held back, so the privacy banner is not shown
|
||||
// over a message that has no remote content at all.
|
||||
@@ -794,7 +799,11 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
name: target.name,
|
||||
label: FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name,
|
||||
}))}
|
||||
document={readerDocument}
|
||||
document={readerBody?.document ?? null}
|
||||
paper={readerBody?.paper ?? true}
|
||||
canAdapt={readerBody?.canAdapt ?? false}
|
||||
showingOriginal={showOriginal}
|
||||
onToggleOriginal={() => setShowOriginal((v) => !v)}
|
||||
frameHeight={readerHeight}
|
||||
hasBlockedContent={hasBlockedContent}
|
||||
imagesLoaded={loadRemoteImages || senderTrusted}
|
||||
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,14 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="lm-tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#6F7EFF"/>
|
||||
<stop offset="1" stop-color="#3441D8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="15" fill="url(#lm-tile)"/>
|
||||
<!-- the flap of an envelope, as light on the tile: the fold every letter has -->
|
||||
<path d="M0 15C0 6.7 6.7 0 15 0h34c8.3 0 15 6.7 15 15v2L32 40 0 17z" fill="#fff" fill-opacity=".11"/>
|
||||
<!-- the lambda -->
|
||||
<path d="M21 14.5 43 50" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.1 32.4 21 50" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- a lambda built as two square-cut strokes, on a tile with continuous corners -->
|
||||
<path d="M64.00 32.00 L64.00 38.34 L63.99 40.36 L63.98 41.83 L63.97 43.03 L63.95 44.06 L63.93 44.97 L63.90 45.79 L63.88 46.54 L63.84 47.24 L63.80 47.89 L63.76 48.50 L63.72 49.07 L63.67 49.62 L63.62 50.14 L63.56 50.64 L63.50 51.11 L63.43 51.57 L63.36 52.01 L63.29 52.43 L63.21 52.83 L63.13 53.23 L63.05 53.61 L62.96 53.97 L62.86 54.33 L62.77 54.67 L62.66 55.01 L62.56 55.33 L62.45 55.65 L62.33 55.95 L62.21 56.25 L62.09 56.54 L61.96 56.82 L61.83 57.10 L61.69 57.36 L61.55 57.62 L61.40 57.87 L61.25 58.12 L61.09 58.36 L60.93 58.59 L60.76 58.81 L60.59 59.04 L60.42 59.25 L60.24 59.46 L60.05 59.66 L59.86 59.86 L59.66 60.05 L59.46 60.24 L59.25 60.42 L59.04 60.59 L58.81 60.76 L58.59 60.93 L58.36 61.09 L58.12 61.25 L57.87 61.40 L57.62 61.55 L57.36 61.69 L57.10 61.83 L56.82 61.96 L56.54 62.09 L56.25 62.21 L55.95 62.33 L55.65 62.45 L55.33 62.56 L55.01 62.66 L54.67 62.77 L54.33 62.86 L53.97 62.96 L53.61 63.05 L53.23 63.13 L52.83 63.21 L52.43 63.29 L52.01 63.36 L51.57 63.43 L51.11 63.50 L50.64 63.56 L50.14 63.62 L49.62 63.67 L49.07 63.72 L48.50 63.76 L47.89 63.80 L47.24 63.84 L46.54 63.88 L45.79 63.90 L44.97 63.93 L44.06 63.95 L43.03 63.97 L41.83 63.98 L40.36 63.99 L38.34 64.00 L32.00 64.00 L25.66 64.00 L23.64 63.99 L22.17 63.98 L20.97 63.97 L19.94 63.95 L19.03 63.93 L18.21 63.90 L17.46 63.88 L16.76 63.84 L16.11 63.80 L15.50 63.76 L14.93 63.72 L14.38 63.67 L13.86 63.62 L13.36 63.56 L12.89 63.50 L12.43 63.43 L11.99 63.36 L11.57 63.29 L11.17 63.21 L10.77 63.13 L10.39 63.05 L10.03 62.96 L9.67 62.86 L9.33 62.77 L8.99 62.66 L8.67 62.56 L8.35 62.45 L8.05 62.33 L7.75 62.21 L7.46 62.09 L7.18 61.96 L6.90 61.83 L6.64 61.69 L6.38 61.55 L6.13 61.40 L5.88 61.25 L5.64 61.09 L5.41 60.93 L5.19 60.76 L4.96 60.59 L4.75 60.42 L4.54 60.24 L4.34 60.05 L4.14 59.86 L3.95 59.66 L3.76 59.46 L3.58 59.25 L3.41 59.04 L3.24 58.81 L3.07 58.59 L2.91 58.36 L2.75 58.12 L2.60 57.87 L2.45 57.62 L2.31 57.36 L2.17 57.10 L2.04 56.82 L1.91 56.54 L1.79 56.25 L1.67 55.95 L1.55 55.65 L1.44 55.33 L1.34 55.01 L1.23 54.67 L1.14 54.33 L1.04 53.97 L0.95 53.61 L0.87 53.23 L0.79 52.83 L0.71 52.43 L0.64 52.01 L0.57 51.57 L0.50 51.11 L0.44 50.64 L0.38 50.14 L0.33 49.62 L0.28 49.07 L0.24 48.50 L0.20 47.89 L0.16 47.24 L0.12 46.54 L0.10 45.79 L0.07 44.97 L0.05 44.06 L0.03 43.03 L0.02 41.83 L0.01 40.36 L0.00 38.34 L0.00 32.00 L0.00 25.66 L0.01 23.64 L0.02 22.17 L0.03 20.97 L0.05 19.94 L0.07 19.03 L0.10 18.21 L0.12 17.46 L0.16 16.76 L0.20 16.11 L0.24 15.50 L0.28 14.93 L0.33 14.38 L0.38 13.86 L0.44 13.36 L0.50 12.89 L0.57 12.43 L0.64 11.99 L0.71 11.57 L0.79 11.17 L0.87 10.77 L0.95 10.39 L1.04 10.03 L1.14 9.67 L1.23 9.33 L1.34 8.99 L1.44 8.67 L1.55 8.35 L1.67 8.05 L1.79 7.75 L1.91 7.46 L2.04 7.18 L2.17 6.90 L2.31 6.64 L2.45 6.38 L2.60 6.13 L2.75 5.88 L2.91 5.64 L3.07 5.41 L3.24 5.19 L3.41 4.96 L3.58 4.75 L3.76 4.54 L3.95 4.34 L4.14 4.14 L4.34 3.95 L4.54 3.76 L4.75 3.58 L4.96 3.41 L5.19 3.24 L5.41 3.07 L5.64 2.91 L5.88 2.75 L6.13 2.60 L6.38 2.45 L6.64 2.31 L6.90 2.17 L7.18 2.04 L7.46 1.91 L7.75 1.79 L8.05 1.67 L8.35 1.55 L8.67 1.44 L8.99 1.34 L9.33 1.23 L9.67 1.14 L10.03 1.04 L10.39 0.95 L10.77 0.87 L11.17 0.79 L11.57 0.71 L11.99 0.64 L12.43 0.57 L12.89 0.50 L13.36 0.44 L13.86 0.38 L14.38 0.33 L14.93 0.28 L15.50 0.24 L16.11 0.20 L16.76 0.16 L17.46 0.12 L18.21 0.10 L19.03 0.07 L19.94 0.05 L20.97 0.03 L22.17 0.02 L23.64 0.01 L25.66 0.00 L32.00 0.00 L38.34 0.00 L40.36 0.01 L41.83 0.02 L43.03 0.03 L44.06 0.05 L44.97 0.07 L45.79 0.10 L46.54 0.12 L47.24 0.16 L47.89 0.20 L48.50 0.24 L49.07 0.28 L49.62 0.33 L50.14 0.38 L50.64 0.44 L51.11 0.50 L51.57 0.57 L52.01 0.64 L52.43 0.71 L52.83 0.79 L53.23 0.87 L53.61 0.95 L53.97 1.04 L54.33 1.14 L54.67 1.23 L55.01 1.34 L55.33 1.44 L55.65 1.55 L55.95 1.67 L56.25 1.79 L56.54 1.91 L56.82 2.04 L57.10 2.17 L57.36 2.31 L57.62 2.45 L57.87 2.60 L58.12 2.75 L58.36 2.91 L58.59 3.07 L58.81 3.24 L59.04 3.41 L59.25 3.58 L59.46 3.76 L59.66 3.95 L59.86 4.14 L60.05 4.34 L60.24 4.54 L60.42 4.75 L60.59 4.96 L60.76 5.19 L60.93 5.41 L61.09 5.64 L61.25 5.88 L61.40 6.13 L61.55 6.38 L61.69 6.64 L61.83 6.90 L61.96 7.18 L62.09 7.46 L62.21 7.75 L62.33 8.05 L62.45 8.35 L62.56 8.67 L62.66 8.99 L62.77 9.33 L62.86 9.67 L62.96 10.03 L63.05 10.39 L63.13 10.77 L63.21 11.17 L63.29 11.57 L63.36 11.99 L63.43 12.43 L63.50 12.89 L63.56 13.36 L63.62 13.86 L63.67 14.38 L63.72 14.93 L63.76 15.50 L63.80 16.11 L63.84 16.76 L63.88 17.46 L63.90 18.21 L63.93 19.03 L63.95 19.94 L63.97 20.97 L63.98 22.17 L63.99 23.64 L64.00 25.66Z" fill="#1B2BD0"/>
|
||||
<path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 798 B After Width: | Height: | Size: 4.7 KiB |
@@ -1,36 +1,20 @@
|
||||
import React, { useId } from "react";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* The LambdaMail mark: a lambda on an ultramarine tile, with the flap of an
|
||||
* envelope lit across the top. The same drawing as app/icon.svg, inline so it
|
||||
* costs no request and takes any size.
|
||||
* The LambdaMail mark: a lambda built as two square-cut strokes of one weight,
|
||||
* white on an ultramarine tile with continuous corners. The same drawing as
|
||||
* app/icon.svg and the generated icons, inline so it costs no request and takes
|
||||
* any size. Flat on purpose: it has to hold at 16 pixels and on either theme.
|
||||
*/
|
||||
const TILE = "M64.00 32.00 L64.00 38.34 L63.99 40.36 L63.98 41.83 L63.97 43.03 L63.95 44.06 L63.93 44.97 L63.90 45.79 L63.88 46.54 L63.84 47.24 L63.80 47.89 L63.76 48.50 L63.72 49.07 L63.67 49.62 L63.62 50.14 L63.56 50.64 L63.50 51.11 L63.43 51.57 L63.36 52.01 L63.29 52.43 L63.21 52.83 L63.13 53.23 L63.05 53.61 L62.96 53.97 L62.86 54.33 L62.77 54.67 L62.66 55.01 L62.56 55.33 L62.45 55.65 L62.33 55.95 L62.21 56.25 L62.09 56.54 L61.96 56.82 L61.83 57.10 L61.69 57.36 L61.55 57.62 L61.40 57.87 L61.25 58.12 L61.09 58.36 L60.93 58.59 L60.76 58.81 L60.59 59.04 L60.42 59.25 L60.24 59.46 L60.05 59.66 L59.86 59.86 L59.66 60.05 L59.46 60.24 L59.25 60.42 L59.04 60.59 L58.81 60.76 L58.59 60.93 L58.36 61.09 L58.12 61.25 L57.87 61.40 L57.62 61.55 L57.36 61.69 L57.10 61.83 L56.82 61.96 L56.54 62.09 L56.25 62.21 L55.95 62.33 L55.65 62.45 L55.33 62.56 L55.01 62.66 L54.67 62.77 L54.33 62.86 L53.97 62.96 L53.61 63.05 L53.23 63.13 L52.83 63.21 L52.43 63.29 L52.01 63.36 L51.57 63.43 L51.11 63.50 L50.64 63.56 L50.14 63.62 L49.62 63.67 L49.07 63.72 L48.50 63.76 L47.89 63.80 L47.24 63.84 L46.54 63.88 L45.79 63.90 L44.97 63.93 L44.06 63.95 L43.03 63.97 L41.83 63.98 L40.36 63.99 L38.34 64.00 L32.00 64.00 L25.66 64.00 L23.64 63.99 L22.17 63.98 L20.97 63.97 L19.94 63.95 L19.03 63.93 L18.21 63.90 L17.46 63.88 L16.76 63.84 L16.11 63.80 L15.50 63.76 L14.93 63.72 L14.38 63.67 L13.86 63.62 L13.36 63.56 L12.89 63.50 L12.43 63.43 L11.99 63.36 L11.57 63.29 L11.17 63.21 L10.77 63.13 L10.39 63.05 L10.03 62.96 L9.67 62.86 L9.33 62.77 L8.99 62.66 L8.67 62.56 L8.35 62.45 L8.05 62.33 L7.75 62.21 L7.46 62.09 L7.18 61.96 L6.90 61.83 L6.64 61.69 L6.38 61.55 L6.13 61.40 L5.88 61.25 L5.64 61.09 L5.41 60.93 L5.19 60.76 L4.96 60.59 L4.75 60.42 L4.54 60.24 L4.34 60.05 L4.14 59.86 L3.95 59.66 L3.76 59.46 L3.58 59.25 L3.41 59.04 L3.24 58.81 L3.07 58.59 L2.91 58.36 L2.75 58.12 L2.60 57.87 L2.45 57.62 L2.31 57.36 L2.17 57.10 L2.04 56.82 L1.91 56.54 L1.79 56.25 L1.67 55.95 L1.55 55.65 L1.44 55.33 L1.34 55.01 L1.23 54.67 L1.14 54.33 L1.04 53.97 L0.95 53.61 L0.87 53.23 L0.79 52.83 L0.71 52.43 L0.64 52.01 L0.57 51.57 L0.50 51.11 L0.44 50.64 L0.38 50.14 L0.33 49.62 L0.28 49.07 L0.24 48.50 L0.20 47.89 L0.16 47.24 L0.12 46.54 L0.10 45.79 L0.07 44.97 L0.05 44.06 L0.03 43.03 L0.02 41.83 L0.01 40.36 L0.00 38.34 L0.00 32.00 L0.00 25.66 L0.01 23.64 L0.02 22.17 L0.03 20.97 L0.05 19.94 L0.07 19.03 L0.10 18.21 L0.12 17.46 L0.16 16.76 L0.20 16.11 L0.24 15.50 L0.28 14.93 L0.33 14.38 L0.38 13.86 L0.44 13.36 L0.50 12.89 L0.57 12.43 L0.64 11.99 L0.71 11.57 L0.79 11.17 L0.87 10.77 L0.95 10.39 L1.04 10.03 L1.14 9.67 L1.23 9.33 L1.34 8.99 L1.44 8.67 L1.55 8.35 L1.67 8.05 L1.79 7.75 L1.91 7.46 L2.04 7.18 L2.17 6.90 L2.31 6.64 L2.45 6.38 L2.60 6.13 L2.75 5.88 L2.91 5.64 L3.07 5.41 L3.24 5.19 L3.41 4.96 L3.58 4.75 L3.76 4.54 L3.95 4.34 L4.14 4.14 L4.34 3.95 L4.54 3.76 L4.75 3.58 L4.96 3.41 L5.19 3.24 L5.41 3.07 L5.64 2.91 L5.88 2.75 L6.13 2.60 L6.38 2.45 L6.64 2.31 L6.90 2.17 L7.18 2.04 L7.46 1.91 L7.75 1.79 L8.05 1.67 L8.35 1.55 L8.67 1.44 L8.99 1.34 L9.33 1.23 L9.67 1.14 L10.03 1.04 L10.39 0.95 L10.77 0.87 L11.17 0.79 L11.57 0.71 L11.99 0.64 L12.43 0.57 L12.89 0.50 L13.36 0.44 L13.86 0.38 L14.38 0.33 L14.93 0.28 L15.50 0.24 L16.11 0.20 L16.76 0.16 L17.46 0.12 L18.21 0.10 L19.03 0.07 L19.94 0.05 L20.97 0.03 L22.17 0.02 L23.64 0.01 L25.66 0.00 L32.00 0.00 L38.34 0.00 L40.36 0.01 L41.83 0.02 L43.03 0.03 L44.06 0.05 L44.97 0.07 L45.79 0.10 L46.54 0.12 L47.24 0.16 L47.89 0.20 L48.50 0.24 L49.07 0.28 L49.62 0.33 L50.14 0.38 L50.64 0.44 L51.11 0.50 L51.57 0.57 L52.01 0.64 L52.43 0.71 L52.83 0.79 L53.23 0.87 L53.61 0.95 L53.97 1.04 L54.33 1.14 L54.67 1.23 L55.01 1.34 L55.33 1.44 L55.65 1.55 L55.95 1.67 L56.25 1.79 L56.54 1.91 L56.82 2.04 L57.10 2.17 L57.36 2.31 L57.62 2.45 L57.87 2.60 L58.12 2.75 L58.36 2.91 L58.59 3.07 L58.81 3.24 L59.04 3.41 L59.25 3.58 L59.46 3.76 L59.66 3.95 L59.86 4.14 L60.05 4.34 L60.24 4.54 L60.42 4.75 L60.59 4.96 L60.76 5.19 L60.93 5.41 L61.09 5.64 L61.25 5.88 L61.40 6.13 L61.55 6.38 L61.69 6.64 L61.83 6.90 L61.96 7.18 L62.09 7.46 L62.21 7.75 L62.33 8.05 L62.45 8.35 L62.56 8.67 L62.66 8.99 L62.77 9.33 L62.86 9.67 L62.96 10.03 L63.05 10.39 L63.13 10.77 L63.21 11.17 L63.29 11.57 L63.36 11.99 L63.43 12.43 L63.50 12.89 L63.56 13.36 L63.62 13.86 L63.67 14.38 L63.72 14.93 L63.76 15.50 L63.80 16.11 L63.84 16.76 L63.88 17.46 L63.90 18.21 L63.93 19.03 L63.95 19.94 L63.97 20.97 L63.98 22.17 L63.99 23.64 L64.00 25.66Z";
|
||||
const LAMBDA = "M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z";
|
||||
|
||||
export function Logo({ size = 32, className = "" }: { size?: number; className?: string }) {
|
||||
// Several of these can be on one page, and a gradient id must be unique.
|
||||
const id = useId().replace(/:/g, "");
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="LambdaMail"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`tile-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#6F7EFF" />
|
||||
<stop offset="1" stopColor="#3441D8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="15" fill={`url(#tile-${id})`} />
|
||||
<path
|
||||
d="M0 15C0 6.7 6.7 0 15 0h34c8.3 0 15 6.7 15 15v2L32 40 0 17z"
|
||||
fill="#fff"
|
||||
fillOpacity=".11"
|
||||
/>
|
||||
<path d="M21 14.5 43 50" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M32.1 32.4 21 50" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<svg viewBox="0 0 64 64" width={size} height={size} className={className} role="img" aria-label="LambdaMail">
|
||||
<path d={TILE} fill="#1B2BD0" />
|
||||
{/* Shifted left a little so the glyph, not its bounding box, is centred. */}
|
||||
<path d={LAMBDA} fill="#fff" transform="translate(-0.9 0)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useTranslations } from "../i18n/provider";
|
||||
* that sent it. The sandbox has no allow-same-origin: the frame stays on an
|
||||
* opaque origin and can read nothing of this page.
|
||||
*/
|
||||
export function MessageFrame({ document: html }: { document: string }) {
|
||||
export function MessageFrame({ document: html, paper = true }: { document: string; paper?: boolean }) {
|
||||
const t = useTranslations();
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(160);
|
||||
@@ -30,7 +30,7 @@ export function MessageFrame({ document: html }: { document: string }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-white shadow-edge ring-1 ring-ink/10">
|
||||
<div className={paper ? "overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-ink/10" : ""}>
|
||||
<iframe
|
||||
ref={frame}
|
||||
title={t("ui.messageBody")}
|
||||
@@ -38,7 +38,7 @@ export function MessageFrame({ document: html }: { document: string }) {
|
||||
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ height: `${height}px` }}
|
||||
className="block w-full border-0"
|
||||
className="block w-full border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,12 @@ interface Props {
|
||||
moveTargets: MoveTarget[];
|
||||
/** The document the body frame renders, or null for a text-only message. */
|
||||
document: string | null;
|
||||
/** Drawn on a white sheet: designed mail, or a plain one shown as sent. */
|
||||
paper: boolean;
|
||||
/** A plainly written message that is taking the app's colours. */
|
||||
canAdapt: boolean;
|
||||
showingOriginal: boolean;
|
||||
onToggleOriginal: () => void;
|
||||
frameHeight: number;
|
||||
/** Whether anything was held back, so the privacy bar is not always shown. */
|
||||
hasBlockedContent: boolean;
|
||||
@@ -302,14 +308,14 @@ export function MessageReader(props: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-4 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
|
||||
<div className="mx-auto flex w-full max-w-[820px] flex-col gap-5 px-4 pb-14 pt-6 sm:px-8">
|
||||
{message.important && (
|
||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-rose-900/60 px-2.5 py-0.5 text-xs font-medium text-rose-200 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.45)]">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("mail.highImportanceBadge")}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
|
||||
<h1 className="text-[22px] font-semibold leading-[1.22] tracking-[-0.022em] text-slate-50 [overflow-wrap:anywhere] [text-wrap:balance] sm:text-[27px]">
|
||||
{message.subject || t("mail.noSubject")}
|
||||
</h1>
|
||||
|
||||
@@ -439,8 +445,22 @@ export function MessageReader(props: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{props.document && props.canAdapt && (
|
||||
<div className="-mb-2 flex justify-end">
|
||||
<button type="button" onClick={props.onToggleOriginal} className={infoLinkClass}>
|
||||
{props.showingOriginal ? t("viewer.adapt") : t("viewer.asSent")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.document ? (
|
||||
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
|
||||
<div
|
||||
className={
|
||||
props.paper
|
||||
? "overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-ink/10"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<iframe
|
||||
title={t("ui.messageBody")}
|
||||
srcDoc={props.document}
|
||||
@@ -452,15 +472,13 @@ export function MessageReader(props: Props) {
|
||||
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ height: `${props.frameHeight}px` }}
|
||||
className="block w-full border-0"
|
||||
className="block w-full border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-panel px-5 py-5 shadow-edge sm:px-6">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
|
||||
{message.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-[15.5px] leading-[1.68] text-slate-100">
|
||||
{message.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type ThreadSummary,
|
||||
} from "../lib/conversations";
|
||||
import { hasBlockedRemoteContent, messageDocument } from "../lib/renderMessage";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients";
|
||||
import { plainSnippet } from "../lib/snippet";
|
||||
import { notifyMailStateChanged } from "../lib/useFolders";
|
||||
@@ -97,6 +98,9 @@ function initialsOf(name: string, address: string): string {
|
||||
export function ThreadReader(props: Props) {
|
||||
const { thread, folder, ownAddress, trusted } = props;
|
||||
const { t, locale } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
// Plain messages that are shown as sent instead of in the app's colours.
|
||||
const [originals, setOriginals] = useState<Set<string>>(new Set());
|
||||
|
||||
const [infos, setInfos] = useState<ThreadMessageInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -365,13 +369,15 @@ export function ThreadReader(props: Props) {
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
|
||||
<div className="mx-auto flex w-full max-w-[820px] flex-col gap-3 px-4 pb-14 pt-6 sm:px-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
|
||||
<h1 className="text-[22px] font-semibold leading-[1.22] tracking-[-0.022em] text-slate-50 [overflow-wrap:anywhere] [text-wrap:balance] sm:text-[27px]">
|
||||
{thread.subject || t("mail.noSubject")}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[12.5px] text-slate-400">
|
||||
<span>{t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })}</span>
|
||||
{(infos?.length ?? thread.message_count) > 1 && (
|
||||
<span>{t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })}</span>
|
||||
)}
|
||||
{infos && waitingOnMe && lastIncoming && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${
|
||||
@@ -404,7 +410,7 @@ export function ThreadReader(props: Props) {
|
||||
const displayName = info.own ? t("conversations.me") : sender.name || info.from_display_name || sender.address;
|
||||
const trustedHere = loaded ? trusted.isTrusted(loaded.from) : false;
|
||||
const showImages = trustedHere || imagesOnce.has(key);
|
||||
const doc = loaded ? messageDocument(loaded, showImages) : null;
|
||||
const rendered = loaded ? messageDocument(loaded, showImages, { theme, original: originals.has(key) }) : null;
|
||||
const blocked = loaded ? hasBlockedRemoteContent(loaded) : false;
|
||||
|
||||
return (
|
||||
@@ -532,10 +538,29 @@ export function ThreadReader(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc ? (
|
||||
<MessageFrame document={doc} />
|
||||
{rendered?.canAdapt && (
|
||||
<div className="-mb-1 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOriginals((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className={infoLinkClass}
|
||||
>
|
||||
{originals.has(key) ? t("viewer.adapt") : t("viewer.asSent")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rendered ? (
|
||||
<MessageFrame document={rendered.document} paper={rendered.paper} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words rounded-xl bg-dark-bg px-4 py-4 font-sans text-[14.5px] leading-relaxed text-slate-200">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-[15.5px] leading-[1.68] text-slate-100">
|
||||
{loaded.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
@@ -99,3 +99,96 @@ describe("the document the reader frame is given", () => {
|
||||
expect(doc).toContain("lm:reader-height");
|
||||
});
|
||||
});
|
||||
|
||||
import { looksDesigned } from "./lib/emailBody.js";
|
||||
|
||||
describe("telling a designed message from a written one", () => {
|
||||
it("takes a letter between two people for written", () => {
|
||||
expect(looksDesigned("<p>Hi Ana,</p><p>Friday works for me.</p><p>Lucas</p>")).toBe(false);
|
||||
});
|
||||
|
||||
it("takes a coloured background, in any of its spellings, for designed", () => {
|
||||
expect(looksDesigned('<table bgcolor="#336699"><tr><td>x</td></tr></table>')).toBe(true);
|
||||
expect(looksDesigned('<div style="background-color: #0c6">x</div>')).toBe(true);
|
||||
expect(looksDesigned("<style>.hero{background:#112233}</style><p>x</p>")).toBe(true);
|
||||
expect(looksDesigned('<td background="hero.png">x</td>')).toBe(true);
|
||||
expect(looksDesigned('<div style="background:url(https://x.test/a.png)">x</div>')).toBe(true);
|
||||
});
|
||||
|
||||
// Outlook paints white behind everything it sends; that is not a design.
|
||||
it("does not count plain white or nothing at all as a background", () => {
|
||||
expect(looksDesigned('<div style="background-color: rgb(255, 255, 255)">hi</div>')).toBe(false);
|
||||
expect(looksDesigned('<div style="background:#ffffff">hi</div>')).toBe(false);
|
||||
expect(looksDesigned('<div style="background-color: white;">hi</div>')).toBe(false);
|
||||
expect(looksDesigned('<p style="background:transparent">hi</p>')).toBe(false);
|
||||
expect(looksDesigned('<body bgcolor="#FFF"><p>hi</p></body>')).toBe(false);
|
||||
});
|
||||
|
||||
it("takes a layout built from tables for designed even with no colour", () => {
|
||||
expect(looksDesigned("<table><tr><td><table><tr><td><table><tr><td>x</td></tr></table></td></tr></table></td></tr></table>")).toBe(true);
|
||||
expect(looksDesigned("<table><tr><td>a</td><td>b</td></tr></table>")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for nothing", () => {
|
||||
expect(looksDesigned("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a written message takes the app's colours", () => {
|
||||
it("is drawn without a white sheet in dark, with light text", () => {
|
||||
const doc = buildReaderDocument("<p>Hi</p>", { theme: "dark", paper: false });
|
||||
expect(doc).toContain('content="dark"');
|
||||
expect(doc).toContain("#e6e9f0");
|
||||
expect(doc).not.toContain("background:#ffffff");
|
||||
expect(doc).toContain("background:transparent");
|
||||
});
|
||||
|
||||
it("is drawn with dark text in light", () => {
|
||||
const doc = buildReaderDocument("<p>Hi</p>", { theme: "light", paper: false });
|
||||
expect(doc).toContain('content="light"');
|
||||
expect(doc).toContain("#12151c");
|
||||
});
|
||||
|
||||
// Black text its own program gave it must not vanish on a dark ground.
|
||||
it("carries the script that lifts text too dark for the ground", () => {
|
||||
const dark = buildReaderDocument("<p>Hi</p>", { theme: "dark", paper: false });
|
||||
expect(dark).toContain("luminance");
|
||||
expect(buildReaderDocument("<p>Hi</p>", { paper: true })).not.toContain("luminance");
|
||||
});
|
||||
|
||||
it("folds the quoted history of a reply behind a button, in both modes", () => {
|
||||
for (const paper of [true, false]) {
|
||||
const doc = buildReaderDocument("<p>ok</p><blockquote>x</blockquote>", { theme: "dark", paper });
|
||||
expect(doc).toContain("lm-quote-toggle");
|
||||
expect(doc).toContain("blockquote");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The script that lifts dark text runs inside a template literal, where `\d`
|
||||
// silently means "d". The regular expression it reads colours with must not
|
||||
// depend on a backslash, or it matches nothing and a plain message stays a
|
||||
// white slab on a dark ground.
|
||||
describe("the colour-reading script in a written message", () => {
|
||||
it("reads numbers without relying on an escape", () => {
|
||||
const doc = buildReaderDocument("<p>Hi</p>", { theme: "dark", paper: false });
|
||||
const line = doc.split("\n").find((l) => l.includes("const channels")) ?? "";
|
||||
expect(line).toContain("[0-9.]+");
|
||||
expect(line).not.toMatch(/\\d/);
|
||||
});
|
||||
|
||||
it("actually lifts too-dark text and clears white backdrops", () => {
|
||||
const html = `<div style="background-color:rgb(255,255,255)"><p style="color:#000">Hi</p></div>`;
|
||||
const doc = buildReaderDocument(html, { theme: "dark", paper: false });
|
||||
const dom = new DOMParser().parseFromString(doc, "text/html");
|
||||
document.body.innerHTML = dom.body.innerHTML;
|
||||
// The same script, run against a document that has the styles applied.
|
||||
const script = dom.querySelector("script")!.textContent!;
|
||||
const rewritten = script.replace(/parent\.postMessage[\s\S]*?"\*"\);/, "0;").replace("new ResizeObserver(report).observe(document.body)", "0");
|
||||
new Function(rewritten)();
|
||||
const div = document.querySelector("div")! as HTMLElement;
|
||||
const p = document.querySelector("p")! as HTMLElement;
|
||||
expect(div.style.getPropertyValue("background-color")).toBe("transparent");
|
||||
expect(p.style.getPropertyValue("color")).toBe("inherit");
|
||||
});
|
||||
});
|
||||
@@ -33,18 +33,69 @@ export function resolveInlineImages(html: string, images: InlineImages): string
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 light surface mail assumes, and the height
|
||||
* report the host page sizes the frame from all have to travel inside it.
|
||||
* 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): string {
|
||||
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. -->
|
||||
@@ -53,11 +104,13 @@ export function buildReaderDocument(bodyHtml: string): string {
|
||||
with it, and this holds without script. -->
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<style>
|
||||
html { background:#ffffff; }
|
||||
:root { color-scheme:${surface.scheme}; }
|
||||
html { background:${native ? "transparent" : "#ffffff"}; }
|
||||
body {
|
||||
margin:0; padding:24px 28px; background:#ffffff; color:#1d1d1f;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size:15px; line-height:1.55; -webkit-font-smoothing:antialiased;
|
||||
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;
|
||||
@@ -68,12 +121,24 @@ export function buildReaderDocument(bodyHtml: string): string {
|
||||
/* 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:0 0 0 4px; padding-left:12px;
|
||||
border-left:3px solid #cbd5e1; color:#475569;
|
||||
margin:.4em 0 .9em; padding:2px 0 2px 14px;
|
||||
border-left:3px solid ${surface.rule}; color:${surface.muted};
|
||||
}
|
||||
a { color:#4f46e5; }
|
||||
pre { white-space:pre-wrap; }
|
||||
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>
|
||||
@@ -85,6 +150,47 @@ ${bodyHtml}
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "./sanitizer";
|
||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "./emailBody";
|
||||
import { resolveInlineImages, buildReaderDocument, looksDesigned, type InlineImages } from "./emailBody";
|
||||
|
||||
/** What it takes to draw a message's body. */
|
||||
export interface RenderableBody {
|
||||
@@ -14,13 +14,34 @@ export interface RenderableBody {
|
||||
* markup, then resolve the message's own inline parts, then block or restore
|
||||
* remote sources - which is the only part the reader chooses.
|
||||
*/
|
||||
export function messageDocument(message: RenderableBody, showImages: boolean): string | null {
|
||||
export interface RenderedBody {
|
||||
/** The document for the reader frame. */
|
||||
document: string;
|
||||
/** Drawn on a white sheet (designed mail, or the reader asked for the original). */
|
||||
paper: boolean;
|
||||
/** A plain message that could have taken the app's colours but was kept as sent. */
|
||||
canAdapt: boolean;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
theme: "dark" | "light";
|
||||
/** Show a plainly written message as it was sent instead of in the app's colours. */
|
||||
original?: boolean;
|
||||
}
|
||||
|
||||
export function messageDocument(message: RenderableBody, showImages: boolean, options: RenderOptions): RenderedBody | null {
|
||||
if (!message.html) return null;
|
||||
const sanitized = sanitizeEmailHtml(message.html);
|
||||
if (!sanitized) return null;
|
||||
const withInline = resolveInlineImages(sanitized, message.inline_images ?? {});
|
||||
const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
||||
return buildReaderDocument(body);
|
||||
const designed = looksDesigned(sanitized);
|
||||
const paper = designed || Boolean(options.original);
|
||||
return {
|
||||
document: buildReaderDocument(body, { theme: options.theme, paper }),
|
||||
paper,
|
||||
canAdapt: !designed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||