Compare commits

..
2 Commits
Author SHA1 Message Date
lukasandClaude Opus 5.5 a4581ab199 Interface size setting: Default, +10%, +20%
Mailbox display and Settings → Appearance get an interface-size picker,
stored per device in localStorage and applied as CSS zoom on <html>.
Viewport-height layouts divide by --ui-zoom, and code that turns mouse
coordinates or bounding rects into CSS lengths (resizers, compose dock,
emoji picker, editor popups and image handles, swipe-to-delete) divides
by the zoom factor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:39 +01:00
lukasandClaude Opus 5.5 d8e6cca66b Spam banner: inset card, red title, plain-language reasons, translated
The banner sits inside the message body's 16px gutter with room below,
"Looks like spam!" is red, and the rspamd symbols are replaced by up to
three human-readable reasons (neutral symbols are hidden). All strings go
through ctx.t with English and Polish catalog entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:39 +01:00
15 changed files with 253 additions and 36 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
import { uiZoom } from "../lib/uiScale";
// A drag handle between two panels. Vertical (the default) sits between panels // A drag handle between two panels. Vertical (the default) sits between panels
// side by side and reports horizontal movement; horizontal sits between panels // side by side and reports horizontal movement; horizontal sits between panels
// stacked one above the other (the "reading pane at the bottom" layout) and // stacked one above the other (the "reading pane at the bottom" layout) and
@@ -17,7 +19,7 @@ export function Resizer({
let last = vertical ? e.clientX : e.clientY; let last = vertical ? e.clientX : e.clientY;
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
const now = vertical ? ev.clientX : ev.clientY; const now = vertical ? ev.clientX : ev.clientY;
onDelta(now - last); onDelta((now - last) / uiZoom()); // screen px → CSS px under the interface zoom
last = now; last = now;
}; };
const onUp = () => { const onUp = () => {
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { uiZoom } from "../../lib/uiScale";
import { Composer } from "./Composer"; import { Composer } from "./Composer";
import { CHIP_W, type ComposeWin, DOCK_INSET, layoutDock } from "./windows"; import { CHIP_W, type ComposeWin, DOCK_INSET, layoutDock } from "./windows";
@@ -8,7 +9,7 @@ function useViewportSize() {
const read = () => const read = () =>
typeof window === "undefined" typeof window === "undefined"
? { vw: 1280, vh: 800 } ? { vw: 1280, vh: 800 }
: { vw: window.innerWidth, vh: window.innerHeight }; : { vw: window.innerWidth / uiZoom(), vh: window.innerHeight / uiZoom() };
const [size, setSize] = useState(read); const [size, setSize] = useState(read);
useEffect(() => { useEffect(() => {
const onResize = () => setSize(read()); const onResize = () => setSize(read());
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { useTheme } from "../../lib/theme"; import { useTheme } from "../../lib/theme";
import { uiZoom } from "../../lib/uiScale";
// emoji-mart, loaded on first use: its emoji data is a few hundred KB that the // emoji-mart, loaded on first use: its emoji data is a few hundred KB that the
// rest of the app does without. The data is bundled — nothing is fetched from // rest of the app does without. The data is bundled — nothing is fetched from
@@ -82,13 +83,18 @@ export function EmojiButton({
const [pos, setPos] = useState<{ left: number; top: number } | null>(null); const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const open = () => { const open = () => {
const r = btnRef.current?.getBoundingClientRect(); const sr = btnRef.current?.getBoundingClientRect();
if (!r) return; if (!sr) return;
onOpen(); onOpen();
// Screen px → CSS px under the interface zoom, so the picker lands on the button.
const z = uiZoom();
const r = { left: sr.left / z, top: sr.top / z, bottom: sr.bottom / z };
const vw = window.innerWidth / z;
const vh = window.innerHeight / z;
// Below the button, or above it when the window ends first; inside the viewport. // Below the button, or above it when the window ends first; inside the viewport.
const below = r.bottom + 6 + PICKER_H <= window.innerHeight; const below = r.bottom + 6 + PICKER_H <= vh;
setPos({ setPos({
left: Math.max(8, Math.min(r.left, window.innerWidth - PICKER_W - 8)), left: Math.max(8, Math.min(r.left, vw - PICKER_W - 8)),
top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - PICKER_H), top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - PICKER_H),
}); });
}; };
@@ -11,6 +11,7 @@ import {
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { uiZoom } from "../../lib/uiScale";
import { notify } from "../AppDialog"; import { notify } from "../AppDialog";
import { EmojiButton } from "./EmojiButton"; import { EmojiButton } from "./EmojiButton";
import { import {
@@ -581,7 +582,8 @@ export const RichTextEditor = forwardRef<
if (!img || !ed) return null; if (!img || !ed) return null;
const ir = img.getBoundingClientRect(); const ir = img.getBoundingClientRect();
const er = ed.getBoundingClientRect(); const er = ed.getBoundingClientRect();
return { left: ir.left - er.left, top: ir.top - er.top, width: ir.width, height: ir.height }; const z = uiZoom(); // rects are screen px; the handles are placed in CSS px
return { left: (ir.left - er.left) / z, top: (ir.top - er.top) / z, width: ir.width / z, height: ir.height / z };
}, [selImg]); }, [selImg]);
useEffect(() => setBox(computeBox()), [selImg, computeBox]); useEffect(() => setBox(computeBox()), [selImg, computeBox]);
@@ -610,10 +612,11 @@ export const RichTextEditor = forwardRef<
const ed = ref.current; const ed = ref.current;
if (!img || !ed) return; if (!img || !ed) return;
const startX = e.clientX; const startX = e.clientX;
const startW = img.getBoundingClientRect().width; const z = uiZoom();
const startW = img.getBoundingClientRect().width / z;
const maxW = ed.clientWidth - 24; // minus px-3 padding const maxW = ed.clientWidth - 24; // minus px-3 padding
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX; const dx = (ev.clientX - startX) / z;
const grow = corner === "se" || corner === "ne" ? dx : -dx; const grow = corner === "se" || corner === "ne" ? dx : -dx;
img.style.width = `${Math.round(Math.max(24, Math.min(startW + grow, maxW)))}px`; img.style.width = `${Math.round(Math.max(24, Math.min(startW + grow, maxW)))}px`;
img.style.height = "auto"; img.style.height = "auto";
@@ -663,8 +666,9 @@ export const RichTextEditor = forwardRef<
if (!ed || !spellIssue || !stillValid(spellIssue)) return null; if (!ed || !spellIssue || !stillValid(spellIssue)) return null;
const r = spellIssue.range.getBoundingClientRect(); const r = spellIssue.range.getBoundingClientRect();
const er = ed.getBoundingClientRect(); const er = ed.getBoundingClientRect();
const z = uiZoom();
return { return {
left: Math.max(8, Math.min(r.left - er.left, er.width - 300)), left: Math.max(8, Math.min((r.left - er.left) / z, er.width / z - 300)),
top: popupTop(r, spellPopupRef.current?.offsetHeight || 160), top: popupTop(r, spellPopupRef.current?.offsetHeight || 160),
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -714,14 +718,17 @@ export const RichTextEditor = forwardRef<
// A popup's top edge, relative to the editor: just under its anchor, or just // A popup's top edge, relative to the editor: just under its anchor, or just
// above it when there is no room left below — inside the editor's visible // above it when there is no room left below — inside the editor's visible
// box and the window alike — so it is never cut off at the bottom. // box and the window alike — so it is never cut off at the bottom.
const popupTop = (anchor: DOMRect, popupH: number) => { const popupTop = (anchor: DOMRect, cssPopupH: number) => {
// Worked out in screen px (the rects'), returned in CSS px for the style.
const z = uiZoom();
const popupH = cssPopupH * z;
const er = ref.current!.getBoundingClientRect(); const er = ref.current!.getBoundingClientRect();
const bottom = Math.min(er.bottom, window.innerHeight) - 4; const bottom = Math.min(er.bottom, window.innerHeight) - 4;
const top = Math.max(er.top, 0) + 4; const top = Math.max(er.top, 0) + 4;
const below = anchor.bottom + 4; const below = anchor.bottom + 4;
const above = anchor.top - 4 - popupH; const above = anchor.top - 4 - popupH;
const fitsBelow = below + popupH <= bottom; const fitsBelow = below + popupH <= bottom;
return (fitsBelow || above < top ? below : above) - er.top; return ((fitsBelow || above < top ? below : above) - er.top) / z;
}; };
// Where the link popup goes: just under the link (or the text becoming one), // Where the link popup goes: just under the link (or the text becoming one),
@@ -738,7 +745,8 @@ export const RichTextEditor = forwardRef<
} }
const er = ed.getBoundingClientRect(); const er = ed.getBoundingClientRect();
if (!ar || (!ar.width && !ar.height && !ar.top)) return newLinkRange ? { left: 12, top: 12 } : null; if (!ar || (!ar.width && !ar.height && !ar.top)) return newLinkRange ? { left: 12, top: 12 } : null;
const left = Math.max(8, Math.min(ar.left - er.left, er.width - 320)); const z = uiZoom();
const left = Math.max(8, Math.min((ar.left - er.left) / z, er.width / z - 320));
return { left, top: popupTop(ar, linkPopupRef.current?.offsetHeight || 44) }; return { left, top: popupTop(ar, linkPopupRef.current?.offsetHeight || 44) };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selLink, newLinkRange]); }, [selLink, newLinkRange]);
+4 -3
View File
@@ -9,6 +9,7 @@ import { avatarColor } from "../../lib/avatarColor";
import { useSavedThreads } from "../../lib/offline"; import { useSavedThreads } from "../../lib/offline";
import { formatClock, getDateLocale } from "../../lib/timeFormat"; import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types"; import type { Address, ThreadSummary } from "../../lib/types";
import { uiZoom } from "../../lib/uiScale";
import { Icon } from "../Icon"; import { Icon } from "../Icon";
import { Clock } from "../Offline"; import { Clock } from "../Offline";
import { AvatarImage } from "../SenderAvatar"; import { AvatarImage } from "../SenderAvatar";
@@ -554,8 +555,8 @@ function useSwipeToDelete(onDelete?: (id: string) => void) {
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => { onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
const s = start.current; const s = start.current;
if (!s || s.id !== id) return; if (!s || s.id !== id) return;
const dx = e.clientX - s.x; const dx = (e.clientX - s.x) / uiZoom(); // CSS px, like the row's offsetWidth
const dy = e.clientY - s.y; const dy = (e.clientY - s.y) / uiZoom();
if (!s.active) { if (!s.active) {
if (Math.abs(dy) > SWIPE_START_PX && Math.abs(dy) > Math.abs(dx)) { if (Math.abs(dy) > SWIPE_START_PX && Math.abs(dy) > Math.abs(dx)) {
start.current = null; // a scroll start.current = null; // a scroll
@@ -572,7 +573,7 @@ function useSwipeToDelete(onDelete?: (id: string) => void) {
start.current = null; start.current = null;
if (!s || !s.active || s.id !== id) return; if (!s || !s.active || s.id !== id) return;
swipedAt.current = Date.now(); swipedAt.current = Date.now();
const dx = Math.max(0, e.clientX - s.x); const dx = Math.max(0, (e.clientX - s.x) / uiZoom());
if (dx > s.width * SWIPE_DELETE_SHARE) { if (dx > s.width * SWIPE_DELETE_SHARE) {
setState({ id, dx: s.width, dragging: false }); // off it goes setState({ id, dx: s.width, dragging: false }); // off it goes
window.setTimeout(() => { window.setTimeout(() => {
@@ -3,11 +3,13 @@ import { useEffect, useRef } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { ListDividersOption } from "./ListDividersOption"; import { ListDividersOption } from "./ListDividersOption";
import { ReadingPanePicker } from "./ReadingPanePicker"; import { ReadingPanePicker } from "./ReadingPanePicker";
import { UiScaleOption } from "./UiScaleOption";
/** /**
* "Mailbox display": the reading-pane choice and the row dividers, opened from * "Mailbox display": the reading-pane choice, the row dividers and the interface
* size, opened from
* the list's ⋯ menu. Anchored below that menu. Picking a layout applies it and * the list's ⋯ menu. Anchored below that menu. Picking a layout applies it and
* closes the popover; the dividers switch applies at once and leaves it open; * closes the popover; the dividers switch and the size apply at once and leave it open;
* ✕, Escape or a click elsewhere closes it. * ✕, Escape or a click elsewhere closes it.
*/ */
export function MailboxDisplay({ export function MailboxDisplay({
@@ -59,12 +61,16 @@ export function MailboxDisplay({
<> <>
<p className="mb-2 text-[13px] font-medium text-app-fg">{t("display.readingPane")}</p> <p className="mb-2 text-[13px] font-medium text-app-fg">{t("display.readingPane")}</p>
<ReadingPanePicker onPick={onClose} /> <ReadingPanePicker onPick={onClose} />
<div className="mt-4 border-t border-app-border pt-3"> <div className="mt-4 space-y-3 border-t border-app-border pt-3">
<ListDividersOption /> <ListDividersOption />
<UiScaleOption />
</div> </div>
</> </>
) : ( ) : (
<div className="space-y-3">
<ListDividersOption /> <ListDividersOption />
<UiScaleOption />
</div>
)} )}
</div> </div>
); );
@@ -0,0 +1,30 @@
import { useT } from "../../i18n";
import { setUiScale, UI_SCALES, useUiScale } from "../../lib/uiScale";
/** "Interface size" — Default / +10% / +20%, in the ⋯ menu's Mailbox display
* and in Settings → Appearance. Applies at once. */
export function UiScaleOption() {
const t = useT();
const scale = useUiScale();
return (
<div className="flex items-center justify-between gap-3 text-[14px] text-app-fg">
<span>{t("display.size")}</span>
<div role="radiogroup" aria-label={t("display.size")} className="flex gap-1 rounded-lg border border-app-border p-0.5">
{UI_SCALES.map((value) => (
<button
key={value}
type="button"
role="radio"
aria-checked={scale === value}
onClick={() => setUiScale(value)}
className={`rounded px-2.5 py-0.5 ${
scale === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
}`}
>
{value === 1 ? t("display.sizeDefault") : `+${Math.round((value - 1) * 100)}%`}
</button>
))}
</div>
</div>
);
}
+21
View File
@@ -670,6 +670,8 @@ export const en = {
"display.bottom": "Bottom", "display.bottom": "Bottom",
"display.off": "Off", "display.off": "Off",
"display.dividers": "Show a dividing line between rows", "display.dividers": "Show a dividing line between rows",
"display.size": "Interface size",
"display.sizeDefault": "Default",
"admin.mail": "Outgoing system mail", "admin.mail": "Outgoing system mail",
"admin.mailHelp": "admin.mailHelp":
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.", "The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
@@ -876,6 +878,25 @@ export const en = {
"mail.translatedFrom": "Translated from {language}", "mail.translatedFrom": "Translated from {language}",
"mail.translatedFromMany": "Translated from {languages}", "mail.translatedFromMany": "Translated from {languages}",
"mail.translateFailed": "Couldn't translate this message. The translation service may be busy or unavailable.", "mail.translateFailed": "Couldn't translate this message. The translation service may be busy or unavailable.",
"antispam.looksLikeSpam": "Looks like spam!",
"antispam.score": "score {score}",
"antispam.moveToSpam": "Move to Spam",
"antispam.notSpam": "Not spam",
"antispam.trustSender": "Always trust sender",
"antispam.why.bayes": "resembles mail you marked as spam",
"antispam.why.fuzzy": "matches known spam",
"antispam.why.reputation": "sender has a poor reputation",
"antispam.why.blocklist": "sending server is on a spam blocklist",
"antispam.why.badLinks": "links to known spam or phishing sites",
"antispam.why.phishing": "link text hides its real destination",
"antispam.why.authFail": "sender failed authenticity checks",
"antispam.why.forged": "sender address looks forged",
"antispam.why.replyTo": "replies would go to a different address",
"antispam.why.headers": "message is missing standard headers",
"antispam.why.hiddenText": "contains hidden text",
"antispam.why.suspiciousServer": "sending server looks suspicious",
"antispam.why.shouting": "subject written in capitals",
"antispam.why.date": "message date is implausible",
} as const; } as const;
export type MessageKey = keyof typeof en; export type MessageKey = keyof typeof en;
+21
View File
@@ -680,6 +680,8 @@ export const pl: Catalog = {
"display.bottom": "Na dole", "display.bottom": "Na dole",
"display.off": "Wyłączone", "display.off": "Wyłączone",
"display.dividers": "Pokazuj linię między wierszami", "display.dividers": "Pokazuj linię między wierszami",
"display.size": "Rozmiar interfejsu",
"display.sizeDefault": "Domyślny",
"admin.mail": "Wychodząca poczta systemowa", "admin.mail": "Wychodząca poczta systemowa",
"admin.mailHelp": "admin.mailHelp":
"Konto pocztowe, z którego serwer wysyła linki do resetowania hasła. Bez niego „Nie pamiętasz hasła?” niczego nie wyśle.", "Konto pocztowe, z którego serwer wysyła linki do resetowania hasła. Bez niego „Nie pamiętasz hasła?” niczego nie wyśle.",
@@ -902,4 +904,23 @@ export const pl: Catalog = {
"mail.translatedFrom": "Przetłumaczono z języka: {language}", "mail.translatedFrom": "Przetłumaczono z języka: {language}",
"mail.translatedFromMany": "Przetłumaczono z języków: {languages}", "mail.translatedFromMany": "Przetłumaczono z języków: {languages}",
"mail.translateFailed": "Nie udało się przetłumaczyć tej wiadomości. Usługa tłumaczenia może być zajęta lub niedostępna.", "mail.translateFailed": "Nie udało się przetłumaczyć tej wiadomości. Usługa tłumaczenia może być zajęta lub niedostępna.",
"antispam.looksLikeSpam": "Wygląda na spam!",
"antispam.score": "wynik {score}",
"antispam.moveToSpam": "Przenieś do spamu",
"antispam.notSpam": "To nie spam",
"antispam.trustSender": "Zawsze ufaj nadawcy",
"antispam.why.bayes": "przypomina wiadomości oznaczone przez Ciebie jako spam",
"antispam.why.fuzzy": "pasuje do znanego spamu",
"antispam.why.reputation": "nadawca ma złą reputację",
"antispam.why.blocklist": "serwer nadawcy jest na liście spamerów",
"antispam.why.badLinks": "zawiera linki do stron ze spamem lub phishingiem",
"antispam.why.phishing": "tekst linku ukrywa jego prawdziwy adres",
"antispam.why.authFail": "nadawca nie przeszedł weryfikacji autentyczności",
"antispam.why.forged": "adres nadawcy wygląda na podrobiony",
"antispam.why.replyTo": "odpowiedzi trafiłyby na inny adres",
"antispam.why.headers": "brakuje standardowych nagłówków",
"antispam.why.hiddenText": "zawiera ukryty tekst",
"antispam.why.suspiciousServer": "serwer nadawcy wygląda podejrzanie",
"antispam.why.shouting": "temat napisany wielkimi literami",
"antispam.why.date": "data wiadomości jest nieprawdopodobna",
}; };
+75
View File
@@ -0,0 +1,75 @@
// Interface size (Mailbox display / Settings → Appearance): Default, +10% or
// +20%. Per device, in localStorage, like the reading-pane layout.
//
// Applied as CSS `zoom` on <html>, which scales everything evenly — px and rem
// sizes, inline styles, SVG icons and the message iframes alike. Two things zoom
// does NOT hide, and the app compensates for:
// - viewport units are zoomed too, so a 100vh box would overflow; `h-screen`
// and the dvh heights divide by --ui-zoom (tailwind.config.js, index.css).
// - mouse coordinates, getBoundingClientRect() and innerWidth/innerHeight are
// in unzoomed screen px, while a style written from them is zoomed again;
// divide by uiZoom() before turning them into a CSS length.
import { useSyncExternalStore } from "react";
export const UI_SCALES = [1, 1.1, 1.2] as const;
export type UiScale = (typeof UI_SCALES)[number];
const KEY = "ui.scale";
const EVENT = "webmail:ui-scale";
function read(): UiScale {
try {
const v = Number(localStorage.getItem(KEY));
return (UI_SCALES as readonly number[]).includes(v) ? (v as UiScale) : 1;
} catch {
return 1;
}
}
/** The current zoom factor, for converting screen px into CSS px. */
export function uiZoom(): number {
return read();
}
let applied = 1;
function apply(): void {
const z = read();
if (z === applied) return; // the storage event fires for every key
applied = z;
const root = document.documentElement;
root.style.zoom = z === 1 ? "" : String(z);
root.style.setProperty("--ui-zoom", String(z));
// The viewport in CSS px just changed size; let everything that measures it
// on "resize" (panel clamps, the compose dock, popups) measure again.
window.dispatchEvent(new Event("resize"));
}
function subscribe(onChange: () => void): () => void {
// Our own writes (same tab) and other tabs' writes (the storage event).
window.addEventListener(EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}
export function useUiScale(): UiScale {
return useSyncExternalStore(subscribe, read, () => 1);
}
export function setUiScale(scale: UiScale): void {
try {
localStorage.setItem(KEY, String(scale));
} catch {
/* private mode: the choice simply does not stick */
}
window.dispatchEvent(new Event(EVENT));
}
// Applied before the first render (imported from main.tsx), so the app never
// paints at the wrong size, and kept in step with later changes from any tab.
apply();
subscribe(apply);
+1
View File
@@ -8,6 +8,7 @@ import { I18nProvider } from "./i18n";
import { isSessionGone, signedOut } from "./lib/auth"; import { isSessionGone, signedOut } from "./lib/auth";
import "./lib/install"; // catches the install event, which can come before the app renders import "./lib/install"; // catches the install event, which can come before the app renders
import { ThemeProvider } from "./lib/theme"; import { ThemeProvider } from "./lib/theme";
import "./lib/uiScale"; // applies the chosen interface size before the first paint
import "./index.css"; import "./index.css";
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3) import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
+9 -7
View File
@@ -60,6 +60,7 @@ import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/ty
import { useReadingPane } from "../lib/readingPane"; import { useReadingPane } from "../lib/readingPane";
import { useIsMobile } from "../lib/useIsMobile"; import { useIsMobile } from "../lib/useIsMobile";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { uiZoom } from "../lib/uiScale";
import { subscribeWs } from "../lib/ws"; import { subscribeWs } from "../lib/ws";
import { PluginSlot } from "../plugins/PluginSlot"; import { PluginSlot } from "../plugins/PluginSlot";
import { usePluginPages } from "../plugins/manifest"; import { usePluginPages } from "../plugins/manifest";
@@ -213,14 +214,15 @@ export function MailLayout() {
if (!el) return; if (!el) return;
const measure = () => { const measure = () => {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
const z = uiZoom(); // the rect is in screen px; the dock lays out in CSS px
// `right` is the gap to the viewport edge, which is what the dock needs // `right` is the gap to the viewport edge, which is what the dock needs
// to line an expanded window up with the pane — it is non-zero whenever // to line an expanded window up with the pane — it is non-zero whenever
// the right panel is open. `height` only under the list: everywhere else // the right panel is open. `height` only under the list: everywhere else
// the pane runs the full height of the screen. // the pane runs the full height of the screen.
setPane({ setPane({
width: Math.round(r.width), width: Math.round(r.width / z),
right: Math.round(window.innerWidth - r.right), right: Math.round((window.innerWidth - r.right) / z),
height: bottom ? Math.round(r.height) : undefined, height: bottom ? Math.round(r.height / z) : undefined,
}); });
}; };
measure(); measure();
@@ -240,7 +242,7 @@ export function MailLayout() {
// The list's height when the reading pane sits under it. // The list's height when the reading pane sits under it.
// Until dragged, the list takes 45% of the height and the reading pane 55%. // Until dragged, the list takes 45% of the height and the reading pane 55%.
const [listH, setListH] = useState( const [listH, setListH] = useState(
() => Number(localStorage.getItem("panel.listH")) || Math.round(window.innerHeight * 0.45), () => Number(localStorage.getItem("panel.listH")) || Math.round((window.innerHeight / uiZoom()) * 0.45),
); );
// Collapsed sidebar shows icons only (persisted). The mobile drawer is always full. // Collapsed sidebar shows icons only (persisted). The mobile drawer is always full.
const [collapsedPref, setCollapsed] = useState( const [collapsedPref, setCollapsed] = useState(
@@ -253,13 +255,13 @@ export function MailLayout() {
const [rightW, setRightW] = useState(() => Number(localStorage.getItem("panel.rightW")) || 340); const [rightW, setRightW] = useState(() => Number(localStorage.getItem("panel.rightW")) || 340);
const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420); const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420);
// The list can be dragged up to 75% of the window width. // The list can be dragged up to 75% of the window width.
const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75); const clampList = (w: number) => Math.min(Math.max(w, 260), (window.innerWidth / uiZoom()) * 0.75);
const clampRight = (w: number) => Math.min(Math.max(w, 280), 560); const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]); useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]); useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
useEffect(() => localStorage.setItem("panel.listH", String(listH)), [listH]); useEffect(() => localStorage.setItem("panel.listH", String(listH)), [listH]);
// Leave both the list and the reading pane at least a usable strip. // Leave both the list and the reading pane at least a usable strip.
const clampListH = (h: number) => Math.min(Math.max(h, 160), window.innerHeight - 220); const clampListH = (h: number) => Math.min(Math.max(h, 160), window.innerHeight / uiZoom() - 220);
useEffect( useEffect(
() => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"), () => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"),
[collapsedPref], [collapsedPref],
@@ -1166,7 +1168,7 @@ export function MailLayout() {
const paneShown = Boolean(paneDraft || threadId || selectedSub); const paneShown = Boolean(paneDraft || threadId || selectedSub);
return ( return (
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}> <div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[calc(100dvh/var(--ui-zoom,1))]" : "h-screen"}`}>
<BackfillBanner /> <BackfillBanner />
{mobile && ( {mobile && (
// Tapping outside the drawer closes it. // Tapping outside the drawer closes it.
+5 -1
View File
@@ -7,6 +7,7 @@ import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { TypedPasswordInput } from "../../components/TypedPasswordInput"; import { TypedPasswordInput } from "../../components/TypedPasswordInput";
import { ListDividersOption } from "../../components/mail/ListDividersOption"; import { ListDividersOption } from "../../components/mail/ListDividersOption";
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker"; import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
import { UiScaleOption } from "../../components/mail/UiScaleOption";
import { LANGUAGES, translate, useLocale, useT } from "../../i18n"; import { LANGUAGES, translate, useLocale, useT } from "../../i18n";
import type { MessageKey } from "../../i18n/en"; import type { MessageKey } from "../../i18n/en";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
@@ -190,7 +191,7 @@ function MobileSettings({
}; };
return ( return (
<div className="relative h-[100dvh] overflow-hidden bg-app-bg text-app-fg"> <div className="relative h-[calc(100dvh/var(--ui-zoom,1))] overflow-hidden bg-app-bg text-app-fg">
<div className="flex h-full flex-col pt-[env(safe-area-inset-top)]"> <div className="flex h-full flex-col pt-[env(safe-area-inset-top)]">
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-app-border px-2"> <header className="flex h-14 shrink-0 items-center gap-2 border-b border-app-border px-2">
<Link to={from} className="flex items-center rounded-lg px-2 py-2 text-app-accent" aria-label={t("nav.back")}> <Link to={from} className="flex items-center rounded-lg px-2 py-2 text-app-accent" aria-label={t("nav.back")}>
@@ -1444,6 +1445,9 @@ function AppearanceSection() {
<div className="max-w-md pt-2"> <div className="max-w-md pt-2">
<ListDividersOption /> <ListDividersOption />
</div> </div>
<div className="max-w-md pt-2">
<UiScaleOption />
</div>
</div> </div>
<SpellcheckSetting /> <SpellcheckSetting />
<CompletionSetting /> <CompletionSetting />
+4
View File
@@ -4,6 +4,10 @@ export default {
content: ["./index.html", "./admin.html", "./src/**/*.{ts,tsx}"], content: ["./index.html", "./admin.html", "./src/**/*.{ts,tsx}"],
theme: { theme: {
extend: { extend: {
// The interface-size setting zooms <html>, and zoom scales viewport units
// too: divide them back so a full-height layout still fits (lib/uiScale.ts).
height: { screen: "calc(100vh / var(--ui-zoom, 1))" },
minHeight: { screen: "calc(100vh / var(--ui-zoom, 1))" },
fontFamily: { fontFamily: {
sans: ['"Lato"', "system-ui", "-apple-system", '"Segoe UI"', "Roboto", "sans-serif"], sans: ['"Lato"', "system-ui", "-apple-system", '"Segoe UI"', "Roboto", "sans-serif"],
}, },
@@ -6,6 +6,37 @@
// mounts on every message, and a spam warning on ordinary mail is worse than no // mounts on every message, and a spam warning on ordinary mail is worse than no
// warning at all. // warning at all.
// Engine symbols mean nothing to a reader, so the banner explains only the ones
// that point AT spam, in plain words (app catalog keys antispam.why.*). Neutral
// or reassuring symbols (ARC_NA, DKIM_TRACE, R_SPF_ALLOW…) are not reasons and
// stay hidden. Ordered by how telling the reason is; first match wins per symbol.
const REASONS = [
[/^BAYES_SPAM$/, "bayes"],
[/^FUZZY_(DENIED|PROB|BLACK)/, "fuzzy"],
[/^PHISH(ING|ED)/, "phishing"],
[/^(PH_SURBL|DBL_PHISH|URIBL_|SURBL_|DBL_|SEM_URIBL|RSPAMD_URIBL|MSBL_)/, "badLinks"],
[/^(RBL_|RECEIVED_SPAMHAUS|RCVD_IN_(?!DNSWL)|RSPAMD_EMAILBL|MAILSPIKE_BAD)/, "blocklist"],
[/^(BAD_REP_|RWL_.*_BAD|SENDER_REP_SPAM|IP_REPUTATION_SPAM)/, "reputation"],
[/^(R_SPF_(FAIL|SOFTFAIL)|R_DKIM_REJECT|DMARC_POLICY_(REJECT|QUARANTINE|SOFTFAIL)|ARC_REJECT)$/, "authFail"],
[/^(FORGED_|SPOOF_)/, "forged"],
[/^(FREEMAIL_REPLYTO_NEQ_FROM|REPLYTO_DOM_NEQ_FROM_DOM|REPLYTO_ADDR_EQ_TO_ADDR)/, "replyTo"],
[/^(ZERO_FONT|MANY_INVISIBLE_PARTS|R_WHITE_ON_WHITE|HTML_SHORT_LINK_IMG)/, "hiddenText"],
[/^HFILTER_/, "suspiciousServer"],
[/^MISSING_(DATE|MID|FROM|TO)$/, "headers"],
[/^(DATE_IN_FUTURE|DATE_IN_PAST)$/, "date"],
[/^SUBJ_ALL_CAPS$/, "shouting"],
];
/** Distinct plain-language reasons for a verdict's symbols, most telling first. */
function reasons(symbols) {
const found = new Set();
for (const s of symbols || []) {
const hit = REASONS.find(([re]) => re.test(s));
if (hit) found.add(hit[1]);
}
return REASONS.map(([, key]) => key).filter((key) => found.has(key));
}
function esc(s) { function esc(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]); return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
} }
@@ -14,6 +45,7 @@ export default function mount(el, ctx, props) {
const messageId = props && props.messageId; const messageId = props && props.messageId;
if (!messageId) return; if (!messageId) return;
let disposed = false; let disposed = false;
const t = ctx.t;
async function act(path, body) { async function act(path, body) {
await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(body) }); await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(body) });
@@ -31,15 +63,18 @@ export default function mount(el, ctx, props) {
} }
if (disposed || !v || v.action !== "banner") return; if (disposed || !v || v.action !== "banner") return;
const why = (v.symbols || []).slice(0, 4).join(", "); const why = reasons(v.symbols).slice(0, 3).map((key) => t(`antispam.why.${key}`));
const meta = [t("antispam.score", { score: Number(v.score).toFixed(1) }), ...why].join(" · ");
// Inset like the rest of the message body (px-4) with air below, so the card
// does not run edge to edge into the remote-images notice under it.
el.innerHTML = ` el.innerHTML = `
<div class="wm-card" role="status"> <div class="wm-card" role="status" style="margin: 0 16px 10px">
<strong>Looks like spam</strong> <strong style="color: rgb(239 68 68)">${esc(t("antispam.looksLikeSpam"))}</strong>
<span class="wm-muted">score ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}</span> <span class="wm-muted">${esc(meta)}</span>
<div class="wm-row"> <div class="wm-row" style="margin-top: 6px">
<button class="wm-btn" data-a="junk">Move to Spam</button> <button class="wm-btn" data-a="junk">${esc(t("antispam.moveToSpam"))}</button>
<button class="wm-btn-ghost" data-a="ham">Not spam</button> <button class="wm-btn-ghost" data-a="ham">${esc(t("antispam.notSpam"))}</button>
<button class="wm-btn-ghost" data-a="trust">Always trust sender</button> <button class="wm-btn-ghost" data-a="trust">${esc(t("antispam.trustSender"))}</button>
</div> </div>
</div>`; </div>`;