Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4581ab199 | ||
|
|
d8e6cca66b |
@@ -1,3 +1,5 @@
|
||||
import { uiZoom } from "../lib/uiScale";
|
||||
|
||||
// A drag handle between two panels. Vertical (the default) 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
|
||||
@@ -17,7 +19,7 @@ export function Resizer({
|
||||
let last = vertical ? e.clientX : e.clientY;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const now = vertical ? ev.clientX : ev.clientY;
|
||||
onDelta(now - last);
|
||||
onDelta((now - last) / uiZoom()); // screen px → CSS px under the interface zoom
|
||||
last = now;
|
||||
};
|
||||
const onUp = () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { uiZoom } from "../../lib/uiScale";
|
||||
import { Composer } from "./Composer";
|
||||
import { CHIP_W, type ComposeWin, DOCK_INSET, layoutDock } from "./windows";
|
||||
|
||||
@@ -8,7 +9,7 @@ function useViewportSize() {
|
||||
const read = () =>
|
||||
typeof window === "undefined"
|
||||
? { vw: 1280, vh: 800 }
|
||||
: { vw: window.innerWidth, vh: window.innerHeight };
|
||||
: { vw: window.innerWidth / uiZoom(), vh: window.innerHeight / uiZoom() };
|
||||
const [size, setSize] = useState(read);
|
||||
useEffect(() => {
|
||||
const onResize = () => setSize(read());
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
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
|
||||
// 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 open = () => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const sr = btnRef.current?.getBoundingClientRect();
|
||||
if (!sr) return;
|
||||
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.
|
||||
const below = r.bottom + 6 + PICKER_H <= window.innerHeight;
|
||||
const below = r.bottom + 6 + PICKER_H <= vh;
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { uiZoom } from "../../lib/uiScale";
|
||||
import { notify } from "../AppDialog";
|
||||
import { EmojiButton } from "./EmojiButton";
|
||||
import {
|
||||
@@ -581,7 +582,8 @@ export const RichTextEditor = forwardRef<
|
||||
if (!img || !ed) return null;
|
||||
const ir = img.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]);
|
||||
|
||||
useEffect(() => setBox(computeBox()), [selImg, computeBox]);
|
||||
@@ -610,10 +612,11 @@ export const RichTextEditor = forwardRef<
|
||||
const ed = ref.current;
|
||||
if (!img || !ed) return;
|
||||
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 onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const dx = (ev.clientX - startX) / z;
|
||||
const grow = corner === "se" || corner === "ne" ? dx : -dx;
|
||||
img.style.width = `${Math.round(Math.max(24, Math.min(startW + grow, maxW)))}px`;
|
||||
img.style.height = "auto";
|
||||
@@ -663,8 +666,9 @@ export const RichTextEditor = forwardRef<
|
||||
if (!ed || !spellIssue || !stillValid(spellIssue)) return null;
|
||||
const r = spellIssue.range.getBoundingClientRect();
|
||||
const er = ed.getBoundingClientRect();
|
||||
const z = uiZoom();
|
||||
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),
|
||||
};
|
||||
// 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
|
||||
// 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.
|
||||
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 bottom = Math.min(er.bottom, window.innerHeight) - 4;
|
||||
const top = Math.max(er.top, 0) + 4;
|
||||
const below = anchor.bottom + 4;
|
||||
const above = anchor.top - 4 - popupH;
|
||||
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),
|
||||
@@ -738,7 +745,8 @@ export const RichTextEditor = forwardRef<
|
||||
}
|
||||
const er = ed.getBoundingClientRect();
|
||||
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) };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selLink, newLinkRange]);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { avatarColor } from "../../lib/avatarColor";
|
||||
import { useSavedThreads } from "../../lib/offline";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
import { uiZoom } from "../../lib/uiScale";
|
||||
import { Icon } from "../Icon";
|
||||
import { Clock } from "../Offline";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
@@ -554,8 +555,8 @@ function useSwipeToDelete(onDelete?: (id: string) => void) {
|
||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const s = start.current;
|
||||
if (!s || s.id !== id) return;
|
||||
const dx = e.clientX - s.x;
|
||||
const dy = e.clientY - s.y;
|
||||
const dx = (e.clientX - s.x) / uiZoom(); // CSS px, like the row's offsetWidth
|
||||
const dy = (e.clientY - s.y) / uiZoom();
|
||||
if (!s.active) {
|
||||
if (Math.abs(dy) > SWIPE_START_PX && Math.abs(dy) > Math.abs(dx)) {
|
||||
start.current = null; // a scroll
|
||||
@@ -572,7 +573,7 @@ function useSwipeToDelete(onDelete?: (id: string) => void) {
|
||||
start.current = null;
|
||||
if (!s || !s.active || s.id !== id) return;
|
||||
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) {
|
||||
setState({ id, dx: s.width, dragging: false }); // off it goes
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useEffect, useRef } from "react";
|
||||
import { useT } from "../../i18n";
|
||||
import { ListDividersOption } from "./ListDividersOption";
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
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>
|
||||
<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 />
|
||||
<UiScaleOption />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ListDividersOption />
|
||||
<div className="space-y-3">
|
||||
<ListDividersOption />
|
||||
<UiScaleOption />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -670,6 +670,8 @@ export const en = {
|
||||
"display.bottom": "Bottom",
|
||||
"display.off": "Off",
|
||||
"display.dividers": "Show a dividing line between rows",
|
||||
"display.size": "Interface size",
|
||||
"display.sizeDefault": "Default",
|
||||
"admin.mail": "Outgoing system mail",
|
||||
"admin.mailHelp":
|
||||
"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.translatedFromMany": "Translated from {languages}",
|
||||
"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;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
|
||||
@@ -680,6 +680,8 @@ export const pl: Catalog = {
|
||||
"display.bottom": "Na dole",
|
||||
"display.off": "Wyłączone",
|
||||
"display.dividers": "Pokazuj linię między wierszami",
|
||||
"display.size": "Rozmiar interfejsu",
|
||||
"display.sizeDefault": "Domyślny",
|
||||
"admin.mail": "Wychodząca poczta systemowa",
|
||||
"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.",
|
||||
@@ -902,4 +904,23 @@ export const pl: Catalog = {
|
||||
"mail.translatedFrom": "Przetłumaczono z języka: {language}",
|
||||
"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.",
|
||||
"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",
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -8,6 +8,7 @@ import { I18nProvider } from "./i18n";
|
||||
import { isSessionGone, signedOut } from "./lib/auth";
|
||||
import "./lib/install"; // catches the install event, which can come before the app renders
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./lib/uiScale"; // applies the chosen interface size before the first paint
|
||||
import "./index.css";
|
||||
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/ty
|
||||
import { useReadingPane } from "../lib/readingPane";
|
||||
import { useIsMobile } from "../lib/useIsMobile";
|
||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
import { uiZoom } from "../lib/uiScale";
|
||||
import { subscribeWs } from "../lib/ws";
|
||||
import { PluginSlot } from "../plugins/PluginSlot";
|
||||
import { usePluginPages } from "../plugins/manifest";
|
||||
@@ -213,14 +214,15 @@ export function MailLayout() {
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
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
|
||||
// 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 pane runs the full height of the screen.
|
||||
setPane({
|
||||
width: Math.round(r.width),
|
||||
right: Math.round(window.innerWidth - r.right),
|
||||
height: bottom ? Math.round(r.height) : undefined,
|
||||
width: Math.round(r.width / z),
|
||||
right: Math.round((window.innerWidth - r.right) / z),
|
||||
height: bottom ? Math.round(r.height / z) : undefined,
|
||||
});
|
||||
};
|
||||
measure();
|
||||
@@ -240,7 +242,7 @@ export function MailLayout() {
|
||||
// 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%.
|
||||
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.
|
||||
const [collapsedPref, setCollapsed] = useState(
|
||||
@@ -253,13 +255,13 @@ export function MailLayout() {
|
||||
const [rightW, setRightW] = useState(() => Number(localStorage.getItem("panel.rightW")) || 340);
|
||||
const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420);
|
||||
// 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);
|
||||
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
|
||||
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
|
||||
useEffect(() => localStorage.setItem("panel.listH", String(listH)), [listH]);
|
||||
// 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(
|
||||
() => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"),
|
||||
[collapsedPref],
|
||||
@@ -1166,7 +1168,7 @@ export function MailLayout() {
|
||||
const paneShown = Boolean(paneDraft || threadId || selectedSub);
|
||||
|
||||
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 />
|
||||
{mobile && (
|
||||
// Tapping outside the drawer closes it.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||
import { ListDividersOption } from "../../components/mail/ListDividersOption";
|
||||
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
|
||||
import { UiScaleOption } from "../../components/mail/UiScaleOption";
|
||||
import { LANGUAGES, translate, useLocale, useT } from "../../i18n";
|
||||
import type { MessageKey } from "../../i18n/en";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
@@ -190,7 +191,7 @@ function MobileSettings({
|
||||
};
|
||||
|
||||
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)]">
|
||||
<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")}>
|
||||
@@ -1444,6 +1445,9 @@ function AppearanceSection() {
|
||||
<div className="max-w-md pt-2">
|
||||
<ListDividersOption />
|
||||
</div>
|
||||
<div className="max-w-md pt-2">
|
||||
<UiScaleOption />
|
||||
</div>
|
||||
</div>
|
||||
<SpellcheckSetting />
|
||||
<CompletionSetting />
|
||||
|
||||
@@ -4,6 +4,10 @@ export default {
|
||||
content: ["./index.html", "./admin.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
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: {
|
||||
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
|
||||
// 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) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
}
|
||||
@@ -14,6 +45,7 @@ export default function mount(el, ctx, props) {
|
||||
const messageId = props && props.messageId;
|
||||
if (!messageId) return;
|
||||
let disposed = false;
|
||||
const t = ctx.t;
|
||||
|
||||
async function act(path, 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;
|
||||
|
||||
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 = `
|
||||
<div class="wm-card" role="status">
|
||||
<strong>Looks like spam</strong>
|
||||
<span class="wm-muted">score ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}</span>
|
||||
<div class="wm-row">
|
||||
<button class="wm-btn" data-a="junk">Move to Spam</button>
|
||||
<button class="wm-btn-ghost" data-a="ham">Not spam</button>
|
||||
<button class="wm-btn-ghost" data-a="trust">Always trust sender</button>
|
||||
<div class="wm-card" role="status" style="margin: 0 16px 10px">
|
||||
<strong style="color: rgb(239 68 68)">${esc(t("antispam.looksLikeSpam"))}</strong>
|
||||
<span class="wm-muted">${esc(meta)}</span>
|
||||
<div class="wm-row" style="margin-top: 6px">
|
||||
<button class="wm-btn" data-a="junk">${esc(t("antispam.moveToSpam"))}</button>
|
||||
<button class="wm-btn-ghost" data-a="ham">${esc(t("antispam.notSpam"))}</button>
|
||||
<button class="wm-btn-ghost" data-a="trust">${esc(t("antispam.trustSender"))}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user