Compare commits
2
Commits
b122276958
...
a4581ab199
| 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
|
// 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]);
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ListDividersOption />
|
<div className="space-y-3">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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>`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user