Attachment previews, forwarded attachments, row dividers
Attachments in the reading pane are tiles with a preview: images and a PDF's
first page, rendered on the server (Pillow, pypdfium2) as WebP of at most
480 px, cached beside the blob store and in the browser. Tiles are all one
size — at most 230 px wide, narrower so three still fit in a row — and frame
each preview by its shape: close to the tile's it fills it, very tall / wide /
square ones show whole over a blurred copy, small ones are not enlarged, PDFs
show the top of the page. The name bar (checkbox, type, name, size, download)
slides in on hover and adapts to the tile's width; files without a preview
get a large type icon. Previews of opened conversations are kept offline.
Forward now carries the original's attachments: new compose attachments
sharing the stored blobs (POST /messages/{id}/forward-attachments).
Mailbox display / Settings → Appearance: "Show a dividing line between
rows" (off by default, per device), with a new switch. Settings drop the
"On a phone…" note, and "Language" reads "Spellchecking language".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
// An on/off switch: off shows a dark knob with ✕ on a grey track, on a light
|
||||
// knob with ✓ on the accent colour.
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
/** Accessible name, when no visible <label> points at the switch. */
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-6 w-11 shrink-0 rounded-full border transition-colors disabled:opacity-50 ${
|
||||
checked ? "border-app-accent bg-app-accent" : "border-app-border bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute top-1/2 flex h-[18px] w-[18px] -translate-y-1/2 items-center justify-center rounded-full transition-all ${
|
||||
checked ? "left-[21px] bg-white text-app-accent" : "left-[2px] bg-app-fg text-app-bg"
|
||||
}`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{checked ? <path d="M2 5.2l2 2 4-4.4" /> : <path d="M2.8 2.8l4.4 4.4M7.2 2.8l-4.4 4.4" />}
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export interface ComposerInit {
|
||||
// A reply/forward quotes the original: this attribution line sits above the
|
||||
// quoted message body (fetched from the original message's sanitized HTML).
|
||||
quote?: { attribution: string; messageId: string };
|
||||
// A forward: the message whose attachments go along with it.
|
||||
forwardFrom?: string;
|
||||
// A message kept on this device (written offline; lib/scheduled.ts).
|
||||
localId?: string;
|
||||
localState?: LocalState; // "scheduled" when reopened from Scheduled
|
||||
@@ -336,6 +338,25 @@ export function Composer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A forward carries the original's attachments (shared on the server, not
|
||||
// re-uploaded). Once per window: StrictMode runs effects twice in dev, so a
|
||||
// ref — not a cancel flag — keeps it from attaching them twice.
|
||||
const forwardedFrom = useRef(false);
|
||||
useEffect(() => {
|
||||
const source = init.forwardFrom;
|
||||
if (!source || init.draftId || forwardedFrom.current) return;
|
||||
forwardedFrom.current = true;
|
||||
void api
|
||||
.forwardAttachments(source)
|
||||
.then((rows) => {
|
||||
if (rows.length) setAttachments((prev) => [...rows, ...prev]);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!isOfflineError(e)) setAttachError(t("compose.forwardAttachFailed"));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
markDirty();
|
||||
setAttachError("");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { useListDividers } from "../../lib/listDividers";
|
||||
import { useSavedThreads } from "../../lib/offline";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
@@ -140,6 +141,8 @@ export function MessageList({
|
||||
const labelMap = labelsQuery.data ?? {};
|
||||
// Offline: conversations not saved on this device are shown faded.
|
||||
const saved = useSavedThreads();
|
||||
// Optional thin line between rows (Mailbox display).
|
||||
const dividers = useListDividers();
|
||||
|
||||
// The user's own addresses, shown as "me" in the sender column.
|
||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
@@ -212,6 +215,15 @@ export function MessageList({
|
||||
dimmed ? "pointer-events-none opacity-40" : "cursor-pointer"
|
||||
} ${unsaved ? "opacity-45" : ""}`}
|
||||
>
|
||||
{dividers && item.index > 0 && (
|
||||
// Midway through the gap above the row, lined up with its content.
|
||||
<div
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-x-5 h-px bg-app-border ${
|
||||
compact ? "top-2.5" : "top-[3px]"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
|
||||
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useT } from "../../i18n";
|
||||
import { setListDividers, useListDividers } from "../../lib/listDividers";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
/** "Show a dividing line between rows" — under the layout choice, in the ⋯
|
||||
* menu's Mailbox display and in Settings → Appearance. Applies at once. */
|
||||
export function ListDividersOption() {
|
||||
const t = useT();
|
||||
const on = useListDividers();
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 text-[14px] text-app-fg">
|
||||
<span>{t("display.dividers")}</span>
|
||||
<Switch checked={on} onChange={setListDividers} label={t("display.dividers")} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { ListDividersOption } from "./ListDividersOption";
|
||||
import { ReadingPanePicker } from "./ReadingPanePicker";
|
||||
|
||||
/**
|
||||
* "Mailbox display": the reading-pane choice, opened from the list's ⋯ menu.
|
||||
* Anchored below that menu. Picking a layout applies it and closes the popover;
|
||||
* ✕, Escape or a click elsewhere closes it without a change.
|
||||
* "Mailbox display": the reading-pane choice and the row dividers, 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;
|
||||
* ✕, Escape or a click elsewhere closes it.
|
||||
*/
|
||||
export function MailboxDisplay({ onClose }: { onClose: () => void }) {
|
||||
const t = useT();
|
||||
@@ -48,6 +50,9 @@ export function MailboxDisplay({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
<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">
|
||||
<ListDividersOption />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,6 +113,172 @@ function DownloadGlyph() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- tiles ---------------------------------------------------------------------
|
||||
|
||||
// Every tile is the same size: at most 230 px wide, and narrower when the
|
||||
// reading column is, so at least three still fit in a row (gap-2: 2 × 8 px).
|
||||
const TILE_WIDTH = "min(230px, calc((100% - 16px) / 3))";
|
||||
const TILE_RATIO = 230 / 146;
|
||||
|
||||
const THUMB_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "pdf"]);
|
||||
|
||||
/** How a thumbnail sits in its tile, decided once it has loaded. */
|
||||
type Fit =
|
||||
| "loading"
|
||||
| "cover" // close to the tile's shape: fill it, trimming a little
|
||||
| "whole" // very tall / wide / square-ish: all of it, over a blurred copy
|
||||
| "small" // smaller than the tile: as is, not blown up
|
||||
| "page" // a PDF: the top of its first page, like a document
|
||||
| "none"; // no thumbnail: the type icon instead
|
||||
|
||||
function fitFor(img: HTMLImageElement, pdf: boolean, tileWidth: number): Fit {
|
||||
if (pdf) return "page";
|
||||
const w = img.naturalWidth;
|
||||
const h = img.naturalHeight;
|
||||
if (!w || !h) return "none";
|
||||
// The server keeps the original size when it is smaller than its 480 px
|
||||
// limit: smaller than the tile itself means a small picture (an icon, a logo).
|
||||
if (w < tileWidth && h < tileWidth / TILE_RATIO) return "small";
|
||||
const ratio = w / h;
|
||||
return ratio >= TILE_RATIO / 1.5 && ratio <= TILE_RATIO * 1.5 ? "cover" : "whole";
|
||||
}
|
||||
|
||||
function AttachmentTile({
|
||||
messageId,
|
||||
att,
|
||||
checked,
|
||||
onToggle,
|
||||
onOpen,
|
||||
}: {
|
||||
messageId: string;
|
||||
att: AttachmentOut;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
/** Open the in-app preview; absent for types it cannot show (they download). */
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const ext = extOf(att.filename);
|
||||
const pdf = ext === "pdf";
|
||||
const [fit, setFit] = useState<Fit>(THUMB_EXT.has(ext) ? "loading" : "none");
|
||||
const thumb = fit !== "none";
|
||||
const src = api.attachmentThumbUrl(messageId, att.id);
|
||||
const download = api.attachmentDownloadUrl(messageId, att.id);
|
||||
|
||||
const open = () => {
|
||||
if (onOpen) onOpen();
|
||||
else window.location.href = download;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={att.filename}
|
||||
title={att.filename}
|
||||
onClick={open}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}}
|
||||
style={{ width: TILE_WIDTH, aspectRatio: `${TILE_RATIO}` }}
|
||||
className={`wm-tile group/tile relative cursor-pointer overflow-hidden rounded-lg border bg-app-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-app-accent ${
|
||||
checked ? "border-app-accent ring-1 ring-app-accent" : "border-app-border"
|
||||
}`}
|
||||
>
|
||||
{thumb ? (
|
||||
<div className={`absolute inset-0 ${pdf ? "bg-white" : "bg-app-hover"}`}>
|
||||
{fit === "whole" && (
|
||||
// The same picture, blurred, filling the space beside it.
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="absolute inset-0 h-full w-full scale-110 object-cover opacity-60 blur-lg"
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const tile = e.currentTarget.closest<HTMLElement>("[role=button]");
|
||||
setFit(fitFor(e.currentTarget, pdf, tile?.clientWidth ?? 230));
|
||||
}}
|
||||
onError={() => setFit("none")}
|
||||
className={`relative h-full w-full transition-opacity ${
|
||||
fit === "loading" ? "opacity-0" : "opacity-100"
|
||||
} ${
|
||||
fit === "cover"
|
||||
? "object-cover"
|
||||
: fit === "page"
|
||||
? "object-cover object-top"
|
||||
: fit === "small"
|
||||
? "object-scale-down"
|
||||
: "object-contain"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// No picture: the file type, large, above the name bar.
|
||||
<div className="wm-tile-art absolute inset-x-0 top-0 bottom-[46px] flex items-center justify-center bg-app-hover/60 [&_svg]:h-11 [&_svg]:w-11">
|
||||
<AttachmentIcon filename={att.filename} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name bar: over a picture it shows on hover (or when ticked / focused);
|
||||
a tile without one always shows it. */}
|
||||
<div
|
||||
className={`wm-tile-bar absolute inset-x-0 bottom-0 flex h-[46px] items-center gap-2 px-2.5 transition-opacity ${
|
||||
thumb
|
||||
? `bg-black/70 text-white backdrop-blur-sm ${
|
||||
checked ? "opacity-100" : "opacity-0 group-hover/tile:opacity-100 group-focus-within/tile:opacity-100"
|
||||
}`
|
||||
: "border-t border-app-border bg-app-surface text-app-fg"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0 accent-app-accent"
|
||||
aria-label={att.filename}
|
||||
/>
|
||||
<span className="wm-tile-icon shrink-0 [&_svg]:h-5 [&_svg]:w-5">
|
||||
<AttachmentIcon filename={att.filename} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 leading-tight">
|
||||
<span
|
||||
className={`block truncate text-[13px] font-medium ${
|
||||
onOpen ? (thumb ? "text-sky-300" : "text-app-accent") : ""
|
||||
}`}
|
||||
>
|
||||
{att.filename}
|
||||
</span>
|
||||
<span className={`wm-tile-size block text-[11px] ${thumb ? "text-white/70" : "text-app-faint"}`}>
|
||||
{formatSize(att.size_bytes)}
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
href={download}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t("mail.download")}
|
||||
aria-label={`${t("mail.download")} ${att.filename}`}
|
||||
className={`shrink-0 rounded p-1 ${
|
||||
thumb ? "text-white/80 hover:bg-white/15 hover:text-white" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
<DownloadGlyph />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Attachments({ messageId }: { messageId: string }) {
|
||||
const t = useT();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -158,44 +324,14 @@ export function Attachments({ messageId }: { messageId: string }) {
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachments.map((att: AttachmentOut) => (
|
||||
<div
|
||||
<AttachmentTile
|
||||
key={att.id}
|
||||
className="flex min-w-[13rem] max-w-[18rem] items-center gap-2 rounded-md border border-app-border bg-app-surface px-2.5 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(att.id)}
|
||||
onChange={() => toggle(att.id)}
|
||||
className="h-3.5 w-3.5 shrink-0 accent-app-accent"
|
||||
aria-label={att.filename}
|
||||
/>
|
||||
<AttachmentIcon filename={att.filename} />
|
||||
<span className="min-w-0 flex-1">
|
||||
{isPreviewable(att.filename) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPreview(att.id)}
|
||||
title={att.filename}
|
||||
className="block w-full truncate text-left text-sm font-medium text-app-accent hover:underline"
|
||||
>
|
||||
{att.filename}
|
||||
</button>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-medium text-app-fg" title={att.filename}>
|
||||
{att.filename}
|
||||
</span>
|
||||
)}
|
||||
<span className="block text-xs text-app-faint">{formatSize(att.size_bytes)}</span>
|
||||
</span>
|
||||
<a
|
||||
href={api.attachmentDownloadUrl(messageId, att.id)}
|
||||
title={t("mail.download")}
|
||||
aria-label={`${t("mail.download")} ${att.filename}`}
|
||||
className="shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
</a>
|
||||
</div>
|
||||
messageId={messageId}
|
||||
att={att}
|
||||
checked={selected.has(att.id)}
|
||||
onToggle={() => toggle(att.id)}
|
||||
onOpen={isPreviewable(att.filename) ? () => openPreview(att.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -72,6 +72,7 @@ function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): Co
|
||||
kind,
|
||||
subject: `Fwd: ${norm}`,
|
||||
quote,
|
||||
forwardFrom: msg.id,
|
||||
};
|
||||
}
|
||||
const to = kind === "reply_all" ? [from, ...toEmails].filter(Boolean).join(", ") : from;
|
||||
|
||||
@@ -362,7 +362,7 @@ export const en = {
|
||||
"settings.spellcheck": "Spelling and grammar check",
|
||||
"settings.on": "On",
|
||||
"settings.off": "Off",
|
||||
"settings.spellcheckLanguage": "Language",
|
||||
"settings.spellcheckLanguage": "Spellchecking language",
|
||||
"settings.spellcheckAuto": "Auto-detect (each paragraph)",
|
||||
"settings.spellcheckUnavailable": "Not available: this server has no spell-checking engine set up.",
|
||||
"settings.recoveryEmailHint":
|
||||
@@ -484,7 +484,7 @@ export const en = {
|
||||
"display.side": "Side",
|
||||
"display.bottom": "Bottom",
|
||||
"display.off": "Off",
|
||||
"display.phoneNote": "On a phone, mail always opens in a single panel.",
|
||||
"display.dividers": "Show a dividing line between rows",
|
||||
"admin.mail": "Outgoing system mail",
|
||||
"admin.mailHelp":
|
||||
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
|
||||
@@ -561,6 +561,7 @@ export const en = {
|
||||
"offline.draftsNotSaved": "Only drafts written on this device are available offline.",
|
||||
"compose.sendLater": "Schedule",
|
||||
"compose.savedOnDevice": "Saved on this device",
|
||||
"compose.forwardAttachFailed": "Couldn't attach the original message's files.",
|
||||
"compose.attachOnDevice": "On this device — uploaded when you're back online",
|
||||
"compose.onDevice": "On this device",
|
||||
"nav.scheduled": "Scheduled",
|
||||
|
||||
@@ -245,3 +245,29 @@ html[data-offline] .wm-online-only:hover {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Attachment tiles (thread/Attachments.tsx) shrink with the reading column so
|
||||
three fit in a row; their name bar adapts to the tile's own width. */
|
||||
.wm-tile {
|
||||
container-type: inline-size;
|
||||
}
|
||||
@container (max-width: 160px) {
|
||||
.wm-tile-icon,
|
||||
.wm-tile-size {
|
||||
display: none;
|
||||
}
|
||||
.wm-tile-bar {
|
||||
height: 34px;
|
||||
gap: 6px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
@container (max-width: 80px) {
|
||||
/* Too small for a name: the picture (or icon) alone; a click still opens it. */
|
||||
.wm-tile-bar {
|
||||
display: none;
|
||||
}
|
||||
.wm-tile-art {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,9 @@ export const api = {
|
||||
},
|
||||
|
||||
messageHtmlUrl: (id: string) => `/api/messages/${id}/html`,
|
||||
// A forward's attachments: the original's files, as compose attachments.
|
||||
forwardAttachments: (id: string) =>
|
||||
apiFetch<AttachmentOut[]>(`/messages/${id}/forward-attachments`, { method: "POST" }),
|
||||
|
||||
// --- account wizard ---
|
||||
discover: (email: string) =>
|
||||
@@ -411,6 +414,9 @@ export const api = {
|
||||
// --- attachments ---
|
||||
messageAttachments: (messageId: string) =>
|
||||
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
|
||||
// A small preview (images, a PDF's first page); 404 when there is none.
|
||||
attachmentThumbUrl: (messageId: string, attachmentId: string) =>
|
||||
`/api/messages/${messageId}/attachments/${attachmentId}/thumb`,
|
||||
attachmentDownloadUrl: (messageId: string, attachmentId: string) =>
|
||||
`/api/messages/${messageId}/attachments/${attachmentId}?download=1`,
|
||||
// Inline URL (no ?download): safe types are served with their real content-type
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Whether the mail list draws a thin line between its rows (Mailbox display /
|
||||
// Settings → Appearance). Off by default; per device, in localStorage, like the
|
||||
// reading-pane layout it sits under.
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const KEY = "mail.listDividers";
|
||||
const EVENT = "webmail:list-dividers";
|
||||
|
||||
function read(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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 useListDividers(): boolean {
|
||||
return useSyncExternalStore(subscribe, read, () => false);
|
||||
}
|
||||
|
||||
export function setListDividers(on: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, on ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode: the choice simply does not stick */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Link, useLocation, useNavigate, useParams, useSearchParams } from "reac
|
||||
|
||||
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||
import { ListDividersOption } from "../../components/mail/ListDividersOption";
|
||||
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
@@ -926,8 +927,9 @@ function AppearanceSection() {
|
||||
<div className="max-w-md">
|
||||
<ReadingPanePicker />
|
||||
</div>
|
||||
{/* Saved per device, like the theme: the right choice depends on the screen. */}
|
||||
<p className="text-xs text-app-faint">{t("display.phoneNote")}</p>
|
||||
<div className="max-w-md pt-2">
|
||||
<ListDividersOption />
|
||||
</div>
|
||||
</div>
|
||||
<SpellcheckSetting />
|
||||
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||
|
||||
Reference in New Issue
Block a user