collapse cross-account dupes, Lato UI font, mail-list selection & density tuning
Backend: - Collapse identical cross-account deliveries in a thread: new messages.delivery_id (migration 0011) preserves the real Message-ID even when message_id_hdr is synthesized (§7.1); get_thread groups by it and reports also_in_account_ids; the list count counts distinct deliveries. Backfill script for existing rows. Frontend: - Bundle Lato (SIL OFL) as the UI font; unread mail-list rows use Black weight. - Mail-list selection: hover-avatar checkbox, tri-state "select all", bulk Archive/Pin/Delete (Restore/Delete in Archive/Trash/Spam) with a count in the reading pane; unread blue dot; "also delivered to" note in the thread. - Spacing/size tuning: sender-column width, icon gaps, avatar size, bar heights, and a round of -1px font sizes across the sidebar, mail list, and reading pane. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
+210
-1240
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 20 KiB |
@@ -32,13 +32,13 @@ function DraftRow({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
|
||||
{badge && (
|
||||
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[10px] text-app-muted">
|
||||
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[12px] text-app-muted">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-[11px] text-app-faint">{time}</span>
|
||||
<span className="shrink-0 text-[13px] text-app-faint">{time}</span>
|
||||
</div>
|
||||
<div className="truncate text-[13px] font-medium text-app-fg">{subject}</div>
|
||||
<div className="truncate text-[15px] font-medium text-app-fg">{subject}</div>
|
||||
{preview && <div className="truncate text-xs text-app-faint">{preview}</div>}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -60,6 +60,8 @@ export function MessageList({
|
||||
onTrash,
|
||||
onDeletePermanent,
|
||||
onTogglePin,
|
||||
checkedIds,
|
||||
onToggleCheck,
|
||||
}: {
|
||||
threads: ThreadSummary[];
|
||||
selectedId?: string;
|
||||
@@ -70,12 +72,18 @@ export function MessageList({
|
||||
onTrash: (id: string) => void;
|
||||
onDeletePermanent: (id: string) => void;
|
||||
onTogglePin: (id: string, pinned: boolean) => void;
|
||||
checkedIds: Set<string>;
|
||||
onToggleCheck: (id: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// In Trash/Spam/Archive the middle action restores; elsewhere it archives.
|
||||
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
|
||||
|
||||
// Row selection: hovering an avatar reveals a checkbox; once anything is checked,
|
||||
// every row shows a checkbox (selection mode) until all are cleared.
|
||||
const checkMode = checkedIds.size > 0;
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: threads.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
@@ -84,7 +92,7 @@ export function MessageList({
|
||||
});
|
||||
|
||||
if (threads.length === 0) {
|
||||
return <div className="p-6 text-sm text-app-faint">{t("mail.noConversations")}</div>;
|
||||
return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -94,6 +102,7 @@ export function MessageList({
|
||||
const thread = threads[item.index];
|
||||
const unread = thread.unread_count > 0;
|
||||
const selected = selectedId === thread.id;
|
||||
const checked = checkedIds.has(thread.id);
|
||||
const primary = thread.participants[0];
|
||||
const names =
|
||||
thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)";
|
||||
@@ -124,35 +133,67 @@ export function MessageList({
|
||||
>
|
||||
<div
|
||||
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
|
||||
selected ? "bg-app-active" : "group-hover:bg-app-hover"
|
||||
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{/* Avatar + extra-participants badge */}
|
||||
<div className="relative shrink-0">
|
||||
<span
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: avatarBg }}
|
||||
>
|
||||
{initials(primary)}
|
||||
</span>
|
||||
{extra > 0 && (
|
||||
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[9px] font-medium text-app-muted ring-2 ring-app-surface">
|
||||
+{extra}
|
||||
</span>
|
||||
{/* Avatar, replaced by a selection checkbox on avatar-hover or once
|
||||
selection mode is active. An unread dot sits in the left margin. */}
|
||||
<div className="group/av relative ml-5 h-[33px] w-[33px] shrink-0">
|
||||
{unread && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent"
|
||||
/>
|
||||
)}
|
||||
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
|
||||
<span
|
||||
className="flex h-[33px] w-[33px] items-center justify-center rounded-full text-[13px] font-semibold text-white"
|
||||
style={{ backgroundColor: avatarBg }}
|
||||
>
|
||||
{initials(primary)}
|
||||
</span>
|
||||
{extra > 0 && (
|
||||
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[10px] font-medium text-app-muted ring-2 ring-app-surface">
|
||||
+{extra}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("mail.select")}
|
||||
aria-pressed={checked}
|
||||
onClick={stop(() => onToggleCheck(thread.id))}
|
||||
className={`absolute inset-0 items-center justify-center ${
|
||||
checkMode ? "flex" : "hidden group-hover/av:flex"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-[17px] w-[17px] items-center justify-center rounded-md border-2 transition-colors ${
|
||||
checked
|
||||
? "border-app-accent bg-app-accent text-white"
|
||||
: "border-app-faint hover:border-app-muted"
|
||||
}`}
|
||||
>
|
||||
{checked && (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3.5 8.5l3 3 6-7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sender column */}
|
||||
<div className="flex w-40 min-w-0 shrink items-center gap-1.5">
|
||||
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
|
||||
<span
|
||||
className={`truncate text-sm ${
|
||||
unread ? "font-semibold text-app-fg" : "text-app-fg"
|
||||
className={`truncate text-[15px] ${
|
||||
unread ? "font-black text-app-fg" : "text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{names}
|
||||
</span>
|
||||
{thread.message_count > 1 && (
|
||||
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
|
||||
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[12px] text-app-muted">
|
||||
{thread.message_count}
|
||||
</span>
|
||||
)}
|
||||
@@ -161,8 +202,8 @@ export function MessageList({
|
||||
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
|
||||
<div className="ml-6 flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div
|
||||
className={`truncate text-[13px] ${
|
||||
unread ? "font-semibold text-app-fg" : "text-app-fg"
|
||||
className={`truncate text-[14px] ${
|
||||
unread ? "font-black text-app-fg" : "text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{thread.subject}
|
||||
@@ -175,7 +216,7 @@ export function MessageList({
|
||||
space while hidden), and the date is an overlay on the right that
|
||||
is simply hidden on hover to reveal archive + trash — so nothing
|
||||
ever shifts between the normal and hover states. */}
|
||||
<div className="relative flex shrink-0 items-center justify-end gap-4">
|
||||
<div className="relative flex shrink-0 items-center justify-end gap-5">
|
||||
{/* Attachment: fixed slot (reserved even when empty) so the rail
|
||||
width — and thus the excerpt width — is the same in every row. */}
|
||||
<span className="flex w-[17px] shrink-0 justify-center text-app-faint">
|
||||
@@ -221,13 +262,17 @@ export function MessageList({
|
||||
onClick={stop(() =>
|
||||
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
|
||||
)}
|
||||
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||
className="invisible mr-[7px] text-app-muted hover:text-app-fg group-hover:visible"
|
||||
>
|
||||
<Icon name="trash" size={17} />
|
||||
</button>
|
||||
{/* Date overlay, aligned to the right edge (over the archive/trash
|
||||
slots); hidden on hover so the actions show through. */}
|
||||
<span className="absolute inset-y-0 right-0 flex items-center whitespace-nowrap text-[13px] tabular-nums text-app-faint group-hover:hidden">
|
||||
<span
|
||||
className={`absolute inset-y-0 right-0 flex items-center whitespace-nowrap text-[13px] tabular-nums text-app-faint group-hover:hidden ${
|
||||
unread ? "font-bold" : ""
|
||||
}`}
|
||||
>
|
||||
{timeLabel(thread.last_message_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +188,7 @@ function ActionsMenu({
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{t("mail.actions")}
|
||||
<span className="text-[9px]">▾</span>
|
||||
<span className="text-[11px]">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg">
|
||||
@@ -215,6 +215,7 @@ function MessageItem({
|
||||
zIndex,
|
||||
showReplyBar,
|
||||
canReply,
|
||||
alsoIn,
|
||||
onToggle,
|
||||
onReply,
|
||||
onToggleRead,
|
||||
@@ -231,6 +232,7 @@ function MessageItem({
|
||||
zIndex: number;
|
||||
showReplyBar: boolean;
|
||||
canReply: boolean;
|
||||
alsoIn: string[];
|
||||
onToggle: () => void;
|
||||
onReply: (kind: string) => void;
|
||||
onToggleRead: () => void;
|
||||
@@ -286,9 +288,9 @@ function MessageItem({
|
||||
onClick={onToggle}
|
||||
>
|
||||
{avatar()}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
<span className="min-w-0 flex-1 truncate text-[15px]">
|
||||
{senderName}
|
||||
<span className="ml-2 text-app-faint">{message.snippet}</span>
|
||||
<span className="ml-2 text-xs text-app-faint">{message.snippet}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
|
||||
</button>
|
||||
@@ -302,7 +304,7 @@ function MessageItem({
|
||||
className="flex w-full items-baseline justify-between gap-2 text-left"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm">
|
||||
<span className="min-w-0 truncate text-[15px]">
|
||||
{senderName}
|
||||
{message.from_addr[0]?.email && (
|
||||
<span className="text-app-faint"> <{message.from_addr[0].email}></span>
|
||||
@@ -320,7 +322,7 @@ function MessageItem({
|
||||
<span className="truncate">
|
||||
{t("mail.to")} {recipientSummary(message.to_addr)}
|
||||
</span>
|
||||
<span className="text-[9px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
|
||||
<span className="text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
||||
{location && <span className="font-semibold text-app-fg">{location}</span>}
|
||||
@@ -355,6 +357,11 @@ function MessageItem({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{alsoIn.length > 0 && (
|
||||
<div className="mt-0.5 truncate text-xs text-app-faint">
|
||||
{t("mail.alsoIn")} {alsoIn.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{showDetails && (
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-t border-app-border pt-2 text-xs text-app-muted">
|
||||
<dt className="text-app-faint">{t("mail.from")}</dt>
|
||||
@@ -514,8 +521,8 @@ export function ThreadView({
|
||||
|
||||
return (
|
||||
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
|
||||
<div className="sticky top-0 z-10 flex h-[44px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
|
||||
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
||||
<div className="sticky top-0 z-10 flex h-[52px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
|
||||
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
@@ -558,6 +565,9 @@ export function ThreadView({
|
||||
}`;
|
||||
// Reply/Reply-all/Forward make no sense on a trashed message.
|
||||
const canReply = specialUse.get(message.mailbox_id) !== "trash";
|
||||
const alsoIn = (message.also_in_account_ids ?? [])
|
||||
.map((id) => accounts?.find((a) => a.id === id)?.email)
|
||||
.filter((e): e is string => Boolean(e));
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<MessageItem
|
||||
@@ -570,6 +580,7 @@ export function ThreadView({
|
||||
zIndex={z}
|
||||
showReplyBar={isNewest && composing?.anchorId !== message.id}
|
||||
canReply={canReply}
|
||||
alsoIn={alsoIn}
|
||||
onToggle={() => toggle(message.id)}
|
||||
onReply={(kind) => openReply(message, kind)}
|
||||
onToggleRead={() => toggleRead(message)}
|
||||
|
||||
@@ -79,6 +79,11 @@ export const en = {
|
||||
"mail.unpin": "Unpin",
|
||||
"mail.delete": "Delete",
|
||||
"mail.restore": "Restore",
|
||||
"mail.select": "Select conversation",
|
||||
"mail.selectAll": "Select all",
|
||||
"mail.conversationSelected": "1 conversation selected",
|
||||
"mail.conversationsSelected": "{count} conversations selected",
|
||||
"mail.alsoIn": "Also delivered to",
|
||||
"mail.emptyTrash": "Empty trash",
|
||||
"mail.emptySpam": "Empty spam",
|
||||
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
|
||||
|
||||
+74
-1
@@ -2,6 +2,79 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Lato (SIL OFL) — the UI font. Browsers only fetch the weights/styles actually
|
||||
rendered, so declaring the full family is cheap. */
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-Thin.ttf") format("truetype");
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-ThinItalic.ttf") format("truetype");
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-Light.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-LightItalic.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-Italic.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-BoldItalic.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-Black.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
src: url("../fonts/Lato-BlackItalic.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Semantic theme tokens as "R G B" triplets (consumed by tailwind.config.js as
|
||||
rgb(var(--x) / <alpha>)). Light is the default; .dark on <html> flips them. */
|
||||
:root {
|
||||
@@ -42,7 +115,7 @@ body,
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-family: "Lato", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background-color: rgb(var(--app-bg));
|
||||
color: rgb(var(--app-fg));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface MessageEnvelope {
|
||||
has_remote_images: boolean;
|
||||
size_bytes: number;
|
||||
snippet: string;
|
||||
also_in_account_ids: string[];
|
||||
}
|
||||
|
||||
export interface ThreadDetail {
|
||||
|
||||
@@ -54,6 +54,16 @@ export function MailLayout() {
|
||||
);
|
||||
const [page, setPage] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
// Mail-list selection (checkboxes). Lives here so the reading pane can show the
|
||||
// bulk-action bar / count while any conversation is selected.
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
|
||||
const toggleCheck = (id: string) =>
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const [composing, setComposing] = useState<ComposerInit | null>(null);
|
||||
const [composerExpanded, setComposerExpanded] = useState(false);
|
||||
// Bumped each time a composer is opened so the <Composer> remounts (fresh state)
|
||||
@@ -124,6 +134,13 @@ export function MailLayout() {
|
||||
// Reset to the first page whenever the underlying list identity changes.
|
||||
useEffect(() => setPage(0), [view, filter, term, listOrder]);
|
||||
|
||||
// Master "select all" state over the currently listed threads.
|
||||
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
|
||||
const allSelected = pageThreads.length > 0 && listedSelectedCount === pageThreads.length;
|
||||
const someSelected = listedSelectedCount > 0 && !allSelected;
|
||||
const toggleSelectAll = () =>
|
||||
setCheckedIds(allSelected ? new Set() : new Set(pageThreads.map((th) => th.id)));
|
||||
|
||||
const threadsRef = useRef<ThreadSummary[]>(threads);
|
||||
threadsRef.current = threads;
|
||||
|
||||
@@ -269,6 +286,32 @@ export function MailLayout() {
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", id] });
|
||||
};
|
||||
|
||||
// Selection resets when you leave the folder.
|
||||
useEffect(() => setCheckedIds(new Set()), [view]);
|
||||
|
||||
// Bulk actions run the given operation over every selected thread, then clear the
|
||||
// selection and refresh (navigating away if the open thread was among them).
|
||||
const runBulk = async (action: (id: string) => Promise<unknown>) => {
|
||||
const ids = [...checkedIds];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await action(id);
|
||||
} catch {
|
||||
/* keep going */
|
||||
}
|
||||
}
|
||||
setCheckedIds(new Set());
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
if (threadId && ids.includes(threadId)) navigate("/mail");
|
||||
};
|
||||
const bulkArchive = () => runBulk((id) => api.archiveThread(id));
|
||||
const bulkPin = () => runBulk((id) => api.threadFlags(id, ["\\Flagged"], []));
|
||||
const bulkTrash = () => runBulk((id) => api.trashThread(id));
|
||||
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
|
||||
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id));
|
||||
|
||||
// "Empty" a Trash/Spam/Archive folder: permanently delete everything in it. The
|
||||
// work runs in a worker; refresh the views after it and via the WS message.deleted.
|
||||
const [emptying, setEmptying] = useState(false);
|
||||
@@ -327,7 +370,7 @@ export function MailLayout() {
|
||||
}`}
|
||||
>
|
||||
{!collapsed && (
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-app-faint">
|
||||
<span className="text-[13px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("common.appName")}
|
||||
</span>
|
||||
)}
|
||||
@@ -345,7 +388,7 @@ export function MailLayout() {
|
||||
</div>
|
||||
<div className={`pb-2 ${collapsed ? "px-2" : "px-3"}`}>
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-sm font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
title={collapsed ? t("nav.compose") : undefined}
|
||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||
>
|
||||
@@ -353,7 +396,7 @@ export function MailLayout() {
|
||||
{!collapsed && t("nav.compose")}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-sm">
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-[15px]">
|
||||
{FOLDERS.map((f) => {
|
||||
const badge =
|
||||
f.view === "folder:inbox"
|
||||
@@ -375,7 +418,7 @@ export function MailLayout() {
|
||||
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
|
||||
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
|
||||
{!collapsed && badge ? (
|
||||
<span className="ml-auto shrink-0 tabular-nums text-xs text-app-muted">{badge}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
|
||||
) : null}
|
||||
</SidebarItem>
|
||||
);
|
||||
@@ -383,7 +426,7 @@ export function MailLayout() {
|
||||
{/* Per-account views are hidden entirely when collapsed. */}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="px-3 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("settings.accounts")}
|
||||
</div>
|
||||
{accounts.map((a) => (
|
||||
@@ -399,7 +442,7 @@ export function MailLayout() {
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="border-t border-app-border p-2 text-sm">
|
||||
<div className="border-t border-app-border p-2 text-[15px]">
|
||||
<Link
|
||||
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
@@ -426,7 +469,7 @@ export function MailLayout() {
|
||||
</button>
|
||||
{!collapsed && quotaQuery.data && (
|
||||
<div
|
||||
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-xs text-app-faint"
|
||||
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-[13px] text-app-faint"
|
||||
title={t("nav.storageQuota")}
|
||||
>
|
||||
{quotaLabel(quotaQuery.data)}
|
||||
@@ -446,7 +489,7 @@ export function MailLayout() {
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 pr-8 text-sm text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 pr-8 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
placeholder={t("nav.search")}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
@@ -470,7 +513,28 @@ export function MailLayout() {
|
||||
</div>
|
||||
</div>
|
||||
{!isDrafts && (
|
||||
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-sm">
|
||||
<div className="flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-[15px]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("mail.selectAll")}
|
||||
title={t("mail.selectAll")}
|
||||
onClick={toggleSelectAll}
|
||||
className={`ml-[28px] flex h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
|
||||
allSelected || someSelected
|
||||
? "border-app-accent bg-app-accent text-white"
|
||||
: "border-app-faint hover:border-app-muted"
|
||||
}`}
|
||||
>
|
||||
{allSelected ? (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3.5 8.5l3 3 6-7" />
|
||||
</svg>
|
||||
) : someSelected ? (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
|
||||
<path d="M4 8h8" />
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
|
||||
{t("filter.all")}
|
||||
</FilterTab>
|
||||
@@ -483,7 +547,7 @@ export function MailLayout() {
|
||||
<div className="-mr-1.5 ml-auto flex items-center gap-1">
|
||||
{(currentFolder === "trash" || currentFolder === "junk") && (
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
|
||||
className="rounded px-2 py-1 text-[13px] font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
|
||||
onClick={emptyCurrentFolder}
|
||||
disabled={emptying}
|
||||
>
|
||||
@@ -526,10 +590,12 @@ export function MailLayout() {
|
||||
onTrash={listTrash}
|
||||
onDeletePermanent={listDeletePermanent}
|
||||
onTogglePin={listTogglePin}
|
||||
checkedIds={checkedIds}
|
||||
onToggleCheck={toggleCheck}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs text-app-muted">
|
||||
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-[13px] text-app-muted">
|
||||
<button
|
||||
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
|
||||
title={t("nav.refresh")}
|
||||
@@ -569,9 +635,50 @@ export function MailLayout() {
|
||||
|
||||
{/* Thread pane */}
|
||||
<main className="relative min-w-0 flex-1 bg-app-surface">
|
||||
{threadId && detailQuery.data ? (
|
||||
{checkedIds.size > 0 ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
|
||||
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
|
||||
{currentFolder === "archive" ||
|
||||
currentFolder === "trash" ||
|
||||
currentFolder === "junk" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={bulkRestore}>
|
||||
<Icon name="restore" size={16} />
|
||||
{t("mail.restore")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToolbarButton onClick={bulkArchive}>
|
||||
<Icon name="archive" size={16} />
|
||||
{t("mail.archive")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={bulkPin}>
|
||||
<Icon name="pin" size={16} />
|
||||
{t("mail.pin")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={bulkTrash}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
|
||||
{checkedIds.size === 1
|
||||
? t("mail.conversationSelected")
|
||||
: t("mail.conversationsSelected", { count: checkedIds.size })}
|
||||
</div>
|
||||
</div>
|
||||
) : threadId && detailQuery.data ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
|
||||
{currentFolder === "archive" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={restore}>
|
||||
@@ -614,7 +721,7 @@ export function MailLayout() {
|
||||
</ToolbarButton>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-app-faint">
|
||||
<span className="ml-auto text-[13px] text-app-faint">
|
||||
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
|
||||
has_remote_images: false,
|
||||
size_bytes: 1024,
|
||||
snippet: `snippet ${id}`,
|
||||
also_in_account_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,20 @@ export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Lato"', "system-ui", "-apple-system", '"Segoe UI"', "Roboto", "sans-serif"],
|
||||
},
|
||||
// Every named text size bumped +2px over Tailwind's defaults (line-heights too).
|
||||
fontSize: {
|
||||
xs: ["14px", "18px"],
|
||||
sm: ["16px", "22px"],
|
||||
base: ["18px", "26px"],
|
||||
lg: ["20px", "30px"],
|
||||
xl: ["22px", "30px"],
|
||||
"2xl": ["26px", "34px"],
|
||||
"3xl": ["32px", "38px"],
|
||||
"4xl": ["38px", "42px"],
|
||||
},
|
||||
// Semantic tokens backed by CSS variables (see index.css). Using these
|
||||
// instead of raw grays lets every surface switch with the theme.
|
||||
// Make every `border`/`divide` utility theme-aware by default, so a bare
|
||||
|
||||
Reference in New Issue
Block a user