mail list redesign, folder actions, empty-folder, unread badges & empty-state art
Frontend:
- Redesign the mail list rows (avatar + sender + subject/snippet + right rail),
even-spaced attachment/pin/archive/trash icons, hover-reveal actions, pink
always-on pin, monocolor paperclip, row gaps, wider sender-subject gap.
- Sidebar folder badges: Inbox unread + Drafts counts (live via WS + mutations).
- Trash/Spam/Archive: hover Restore icon (replaces Archive), permanent delete in
Trash; "Empty trash" / "Empty spam" buttons on the sort bar.
- Reading pane empty state: email illustration above an enlarged
"Select a conversation".
- Search field clear (X) button; mark-read after a 4s dwell instead of instantly.
Backend:
- GET /api/mailboxes/counts (inbox unread + drafts) for the sidebar badges.
- POST /api/folders/{special}/empty + empty_folder_job to permanently clear a
Trash/Spam/Archive folder (server EXPUNGE + DB rows + thread cleanup).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
@@ -133,6 +133,19 @@ async def delete_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession)
|
|||||||
return {"status": "queued"}
|
return {"status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
|
# Folders whose "Empty" action permanently deletes everything they hold.
|
||||||
|
_EMPTYABLE_FOLDERS = frozenset({"trash", "junk", "archive"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/folders/{special}/empty")
|
||||||
|
async def empty_folder(special: str, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||||
|
"""Permanently delete every message in a Trash/Spam/Archive folder."""
|
||||||
|
if special not in _EMPTYABLE_FOLDERS:
|
||||||
|
raise_api_error(400, "bad_folder", "this folder cannot be emptied")
|
||||||
|
await enqueue_job("empty_folder_job", str(user.id), special)
|
||||||
|
return {"status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/messages/{message_id}/move")
|
@router.post("/messages/{message_id}/move")
|
||||||
async def move_message(
|
async def move_message(
|
||||||
message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession
|
message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.identity.deps import CurrentUser
|
from app.identity.deps import CurrentUser
|
||||||
from app.models import Mailbox
|
from app.models import Draft, Mailbox, Message
|
||||||
|
|
||||||
router = APIRouter(prefix="/mailboxes", tags=["mail"])
|
router = APIRouter(prefix="/mailboxes", tags=["mail"])
|
||||||
|
|
||||||
@@ -26,6 +26,11 @@ class MailboxOut(BaseModel):
|
|||||||
special_use: str | None
|
special_use: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class FolderCounts(BaseModel):
|
||||||
|
inbox_unread: int # unread messages sitting in Inbox mailboxes
|
||||||
|
drafts: int # drafts shown in the Drafts folder (local + un-adopted remote)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[MailboxOut])
|
@router.get("", response_model=list[MailboxOut])
|
||||||
async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]:
|
async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]:
|
||||||
rows = await db.scalars(
|
rows = await db.scalars(
|
||||||
@@ -35,3 +40,38 @@ async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]:
|
|||||||
MailboxOut(id=m.id, account_id=m.account_id, path=m.path, special_use=m.special_use)
|
MailboxOut(id=m.id, account_id=m.account_id, path=m.path, special_use=m.special_use)
|
||||||
for m in rows
|
for m in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/counts", response_model=FolderCounts)
|
||||||
|
async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
|
||||||
|
"""Badge counts for the sidebar: unread Inbox messages and Drafts-folder items."""
|
||||||
|
inbox_unread = await db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Message)
|
||||||
|
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||||
|
.where(
|
||||||
|
Message.user_id == user.id,
|
||||||
|
Mailbox.special_use == "inbox",
|
||||||
|
~Message.flags.contains(["\\Seen"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror
|
||||||
|
# UIDs excluded), matching what list_drafts + list_remote_drafts return.
|
||||||
|
local = await db.scalar(select(func.count()).select_from(Draft).where(Draft.user_id == user.id))
|
||||||
|
owned = {
|
||||||
|
(r.account_id, r.imap_uid)
|
||||||
|
for r in await db.execute(
|
||||||
|
select(Draft.account_id, Draft.imap_uid).where(
|
||||||
|
Draft.user_id == user.id, Draft.imap_uid.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
remote_rows = await db.execute(
|
||||||
|
select(Message.account_id, Message.uid)
|
||||||
|
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||||
|
.where(Message.user_id == user.id, Mailbox.special_use == "drafts")
|
||||||
|
)
|
||||||
|
remote = sum(1 for r in remote_rows if (r.account_id, r.uid) not in owned)
|
||||||
|
|
||||||
|
return FolderCounts(inbox_unread=int(inbox_unread or 0), drafts=int(local or 0) + remote)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import uuid
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import session_scope
|
from app.db import session_scope
|
||||||
@@ -358,3 +358,83 @@ async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
|
|||||||
await get_event_bus().publish(
|
await get_event_bus().publish(
|
||||||
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
|
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str) -> None:
|
||||||
|
"""Permanently delete every message in the user's folder of this special-use.
|
||||||
|
|
||||||
|
Backs the Trash/Spam/Archive "Empty" action: EXPUNGE on the server, drop the DB
|
||||||
|
rows, and re-denormalize (or delete) affected threads.
|
||||||
|
"""
|
||||||
|
uid = uuid.UUID(user_id)
|
||||||
|
async with session_scope() as session:
|
||||||
|
mailboxes = list(
|
||||||
|
await session.scalars(
|
||||||
|
select(Mailbox).where(
|
||||||
|
Mailbox.user_id == uid, Mailbox.special_use == special_use
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not mailboxes:
|
||||||
|
return
|
||||||
|
mb_ids = [mb.id for mb in mailboxes]
|
||||||
|
path_by_id = {mb.id: mb.path for mb in mailboxes}
|
||||||
|
messages = list(
|
||||||
|
await session.scalars(
|
||||||
|
select(Message).where(Message.user_id == uid, Message.mailbox_id.in_(mb_ids))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not messages:
|
||||||
|
return
|
||||||
|
account_ids = list({m.account_id for m in messages})
|
||||||
|
accounts = {
|
||||||
|
a.id: a
|
||||||
|
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
|
||||||
|
}
|
||||||
|
thread_ids = list({m.thread_id for m in messages if m.thread_id is not None})
|
||||||
|
by_box: dict[tuple[uuid.UUID, str], list[int]] = {}
|
||||||
|
for m in messages:
|
||||||
|
by_box.setdefault((m.account_id, path_by_id[m.mailbox_id]), []).append(m.uid)
|
||||||
|
|
||||||
|
for account_id in account_ids:
|
||||||
|
provider = build_provider(accounts[account_id])
|
||||||
|
try:
|
||||||
|
for (acct, path), uids in by_box.items():
|
||||||
|
if acct != account_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await provider.delete_messages(path, uids)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("empty folder: expunge failed for %s in %s", uids, path)
|
||||||
|
finally:
|
||||||
|
await provider.close()
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
msgs = list(
|
||||||
|
await session.scalars(
|
||||||
|
select(Message).where(Message.user_id == uid, Message.mailbox_id.in_(mb_ids))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
msg_ids = [m.id for m in msgs]
|
||||||
|
if msg_ids:
|
||||||
|
await session.execute(
|
||||||
|
update(Draft)
|
||||||
|
.where(Draft.reply_to_message_id.in_(msg_ids))
|
||||||
|
.values(reply_to_message_id=None)
|
||||||
|
)
|
||||||
|
for m in msgs:
|
||||||
|
await session.delete(m)
|
||||||
|
await session.flush()
|
||||||
|
# Threads with no messages left get dropped (drafts un-linked first); the
|
||||||
|
# rest are re-denormalized so their counts/snippet reflect the removal.
|
||||||
|
for tid in thread_ids:
|
||||||
|
remaining = await session.scalar(
|
||||||
|
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
||||||
|
)
|
||||||
|
if not remaining:
|
||||||
|
await session.execute(
|
||||||
|
update(Draft).where(Draft.thread_id == tid).values(thread_id=None)
|
||||||
|
)
|
||||||
|
await denormalize_thread(session, tid)
|
||||||
|
|
||||||
|
await get_event_bus().publish("message.deleted", {"user_id": str(uid)})
|
||||||
|
|||||||
@@ -298,6 +298,22 @@ async def test_thread_detail_hides_trashed_message_unless_viewing_trash(api: Api
|
|||||||
assert trow["message_count"] == 1
|
assert trow["message_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_folder_counts(api: ApiCtx) -> None:
|
||||||
|
assert api.session is not None
|
||||||
|
# All three seeded inbox messages are unread; no drafts yet.
|
||||||
|
resp = await api.client.get("/api/mailboxes/counts")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"inbox_unread": 3, "drafts": 0}
|
||||||
|
|
||||||
|
# Marking one message \Seen lowers the unread count.
|
||||||
|
msg = await api.session.get(Message, api.message_ids[0])
|
||||||
|
assert msg is not None
|
||||||
|
msg.flags = ["\\Seen"]
|
||||||
|
await api.session.commit()
|
||||||
|
body = (await api.client.get("/api/mailboxes/counts")).json()
|
||||||
|
assert body["inbox_unread"] == 2
|
||||||
|
|
||||||
|
|
||||||
async def test_message_html_is_sanitized(api: ApiCtx) -> None:
|
async def test_message_html_is_sanitized(api: ApiCtx) -> None:
|
||||||
# the lunch message has HTML with a script
|
# the lunch message has HTML with a script
|
||||||
resp = await api.client.get("/api/threads")
|
resp = await api.client.get("/api/threads")
|
||||||
|
|||||||
@@ -245,6 +245,18 @@ async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None:
|
|||||||
assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))]
|
assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_folder_enqueues_job(ctx: Ctx) -> None:
|
||||||
|
resp = await ctx.client.post("/api/folders/trash/empty", headers=_hdr())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert ctx.enqueued == [("empty_folder_job", str(ctx.user_id), "trash")]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_folder_rejects_non_removable_folder(ctx: Ctx) -> None:
|
||||||
|
resp = await ctx.client.post("/api/folders/inbox/empty", headers=_hdr())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert ctx.enqueued == []
|
||||||
|
|
||||||
|
|
||||||
async def test_ignores_unknown_flags(ctx: Ctx) -> None:
|
async def test_ignores_unknown_flags(ctx: Ctx) -> None:
|
||||||
mid = ctx.message_ids[0]
|
mid = ctx.message_ids[0]
|
||||||
resp = await ctx.client.post(
|
resp = await ctx.client.post(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.outbox.draft_sync import push_draft_to_imap_job, remove_imap_draft_job
|
|||||||
from app.outbox.jobs import send_job
|
from app.outbox.jobs import send_job
|
||||||
from app.sync.actions import (
|
from app.sync.actions import (
|
||||||
delete_thread_job,
|
delete_thread_job,
|
||||||
|
empty_folder_job,
|
||||||
move_job,
|
move_job,
|
||||||
move_thread_to_folder_job,
|
move_thread_to_folder_job,
|
||||||
push_flags_job,
|
push_flags_job,
|
||||||
@@ -41,6 +42,7 @@ INTERACTIVE_FUNCTIONS = [
|
|||||||
move_thread_to_folder_job,
|
move_thread_to_folder_job,
|
||||||
restore_thread_job,
|
restore_thread_job,
|
||||||
delete_thread_job,
|
delete_thread_job,
|
||||||
|
empty_folder_job,
|
||||||
send_job,
|
send_job,
|
||||||
push_draft_to_imap_job,
|
push_draft_to_imap_job,
|
||||||
remove_imap_draft_job,
|
remove_imap_draft_job,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="64" height="64" viewBox="0 0 48 48" style="color: rgb(74, 85, 101); opacity: 1; transform: rotate(0deg);"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"><path d="m13 8l-7 6l7 7"></path><path d="M6 14h22.994c6.883 0 12.728 5.62 12.996 12.5c.284 7.27-5.723 13.5-12.996 13.5H11.998"></path></g></svg>
|
||||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,6 @@
|
|||||||
|
// Email illustration for the empty reading pane.
|
||||||
|
import illustration from "../../icons/email-illustration.svg";
|
||||||
|
|
||||||
|
export function EmptyIllustration({ className = "" }: { className?: string }) {
|
||||||
|
return <img src={illustration} alt="" aria-hidden="true" className={className} />;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import pin from "../../icons/pin.svg?raw";
|
|||||||
import print from "../../icons/print.svg?raw";
|
import print from "../../icons/print.svg?raw";
|
||||||
import replyAll from "../../icons/reply-all-line.svg?raw";
|
import replyAll from "../../icons/reply-all-line.svg?raw";
|
||||||
import reply from "../../icons/reply.svg?raw";
|
import reply from "../../icons/reply.svg?raw";
|
||||||
|
import restore from "../../icons/restore.svg?raw";
|
||||||
import sent from "../../icons/sent-02.svg?raw";
|
import sent from "../../icons/sent-02.svg?raw";
|
||||||
import settings from "../../icons/settings.svg?raw";
|
import settings from "../../icons/settings.svg?raw";
|
||||||
import show from "../../icons/show.svg?raw";
|
import show from "../../icons/show.svg?raw";
|
||||||
@@ -35,6 +36,7 @@ const RAW: Record<string, string> = {
|
|||||||
print,
|
print,
|
||||||
reply,
|
reply,
|
||||||
"reply-all": replyAll,
|
"reply-all": replyAll,
|
||||||
|
restore,
|
||||||
sent,
|
sent,
|
||||||
settings,
|
settings,
|
||||||
show,
|
show,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import type { AccountOut, ThreadSummary } from "../../lib/types";
|
import type { Address, ThreadSummary } from "../../lib/types";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
|
|
||||||
function timeLabel(iso: string): string {
|
function timeLabel(iso: string): string {
|
||||||
@@ -15,26 +15,71 @@ function timeLabel(iso: string): string {
|
|||||||
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
|
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
|
||||||
|
function hue(key: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(p: Address | undefined): string {
|
||||||
|
const name = (p?.name || p?.email || "?").trim();
|
||||||
|
const words = name.split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length >= 2) return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monocolor paperclip (inherits currentColor) — replaces the old emoji.
|
||||||
|
function Paperclip() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Virtualized thread list (SPEC §2 list/MessageList.tsx).
|
// Virtualized thread list (SPEC §2 list/MessageList.tsx).
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
threads,
|
threads,
|
||||||
accounts,
|
|
||||||
selectedId,
|
selectedId,
|
||||||
|
folder,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onArchive,
|
||||||
|
onRestore,
|
||||||
|
onTrash,
|
||||||
|
onDeletePermanent,
|
||||||
|
onTogglePin,
|
||||||
}: {
|
}: {
|
||||||
threads: ThreadSummary[];
|
threads: ThreadSummary[];
|
||||||
accounts: AccountOut[];
|
|
||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
|
folder: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
onArchive: (id: string) => void;
|
||||||
|
onRestore: (id: string) => void;
|
||||||
|
onTrash: (id: string) => void;
|
||||||
|
onDeletePermanent: (id: string) => void;
|
||||||
|
onTogglePin: (id: string, pinned: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const colorFor = (id: string) => accounts.find((a) => a.id === id)?.color ?? "#888888";
|
// In Trash/Spam/Archive the middle action restores; elsewhere it archives.
|
||||||
|
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: threads.length,
|
count: threads.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
estimateSize: () => 82,
|
estimateSize: () => 75, // 68 row + 7px gap
|
||||||
overscan: 8,
|
overscan: 8,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,10 +94,24 @@ export function MessageList({
|
|||||||
const thread = threads[item.index];
|
const thread = threads[item.index];
|
||||||
const unread = thread.unread_count > 0;
|
const unread = thread.unread_count > 0;
|
||||||
const selected = selectedId === thread.id;
|
const selected = selectedId === thread.id;
|
||||||
|
const primary = thread.participants[0];
|
||||||
|
const names =
|
||||||
|
thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)";
|
||||||
|
const extra = Math.max(0, thread.participants.length - 1);
|
||||||
|
const avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`;
|
||||||
|
const stop = (fn: () => void) => (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
fn();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => onSelect(thread.id)}
|
onClick={() => onSelect(thread.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") onSelect(thread.id);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -61,41 +120,119 @@ export function MessageList({
|
|||||||
height: item.size,
|
height: item.size,
|
||||||
transform: `translateY(${item.start}px)`,
|
transform: `translateY(${item.start}px)`,
|
||||||
}}
|
}}
|
||||||
className={`flex flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${
|
className="group absolute inset-x-0 cursor-pointer px-2 pt-[7px]"
|
||||||
selected
|
|
||||||
? "bg-app-active"
|
|
||||||
: "hover:bg-app-hover"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<span className="flex shrink-0 gap-0.5">
|
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
|
||||||
{thread.account_ids.map((id) => (
|
selected ? "bg-app-active" : "group-hover:bg-app-hover"
|
||||||
<span
|
}`}
|
||||||
key={id}
|
>
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
{/* Avatar + extra-participants badge */}
|
||||||
style={{ backgroundColor: colorFor(id) }}
|
<div className="relative shrink-0">
|
||||||
/>
|
<span
|
||||||
))}
|
className="flex h-10 w-10 items-center justify-center rounded-full text-xs font-semibold text-white"
|
||||||
</span>
|
style={{ backgroundColor: avatarBg }}
|
||||||
<span className={`flex-1 truncate text-sm ${unread ? "font-semibold text-app-fg" : "text-app-fg"}`}>
|
>
|
||||||
{thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"}
|
{initials(primary)}
|
||||||
</span>
|
|
||||||
{thread.message_count > 1 && (
|
|
||||||
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
|
|
||||||
{thread.message_count}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{extra > 0 && (
|
||||||
{thread.pinned && (
|
<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">
|
||||||
<Icon name="pin" size={13} className="text-[rgb(220_129_143)]" />
|
+{extra}
|
||||||
)}
|
</span>
|
||||||
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</span>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sender column */}
|
||||||
|
<div className="flex w-40 min-w-0 shrink items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`truncate text-sm ${
|
||||||
|
unread ? "font-semibold 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">
|
||||||
|
{thread.message_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{thread.subject}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right rail: four evenly-spaced icons, vertically centred. The
|
||||||
|
paperclip and pin keep fixed slots (archive/trash reserve their
|
||||||
|
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">
|
||||||
|
{/* 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">
|
||||||
|
{thread.has_attachments && <Paperclip />}
|
||||||
|
</span>
|
||||||
|
{/* Pin: pink and always shown when pinned; otherwise revealed on hover. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={thread.pinned ? t("mail.unpin") : t("mail.pin")}
|
||||||
|
onClick={stop(() => onTogglePin(thread.id, thread.pinned))}
|
||||||
|
className={
|
||||||
|
thread.pinned
|
||||||
|
? "text-[rgb(220_129_143)]"
|
||||||
|
: "invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="pin" size={17} />
|
||||||
|
</button>
|
||||||
|
{/* Archive (or Restore in Trash/Spam/Archive) + delete: hover-only,
|
||||||
|
but reserve their slots always. In Trash, delete is permanent. */}
|
||||||
|
{isRemovalFolder ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("mail.restore")}
|
||||||
|
onClick={stop(() => onRestore(thread.id))}
|
||||||
|
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||||
|
>
|
||||||
|
<Icon name="restore" size={17} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("mail.archive")}
|
||||||
|
onClick={stop(() => onArchive(thread.id))}
|
||||||
|
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
|
||||||
|
>
|
||||||
|
<Icon name="archive" size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("mail.delete")}
|
||||||
|
onClick={stop(() =>
|
||||||
|
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
|
||||||
|
)}
|
||||||
|
className="invisible 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">
|
||||||
|
{timeLabel(thread.last_message_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`truncate text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
|
</div>
|
||||||
{thread.subject}
|
|
||||||
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -423,6 +423,8 @@ export function ThreadView({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["thread"] });
|
queryClient.invalidateQueries({ queryKey: ["thread"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
// Mark read/unread changes the Inbox unread badge.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const move = useMutation({
|
const move = useMutation({
|
||||||
@@ -431,6 +433,7 @@ export function ThreadView({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["thread"] });
|
queryClient.invalidateQueries({ queryKey: ["thread"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -518,7 +521,7 @@ export function ThreadView({
|
|||||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
title={t("mail.print")}
|
title={t("mail.print")}
|
||||||
aria-label={t("mail.print")}
|
aria-label={t("mail.print")}
|
||||||
onClick={() => void printMessages(subject, messages)}
|
onClick={() => void printMessages(subject, visual)}
|
||||||
>
|
>
|
||||||
<Icon name="print" size={16} />
|
<Icon name="print" size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const en = {
|
|||||||
"common.remove": "Remove",
|
"common.remove": "Remove",
|
||||||
"common.edit": "Edit",
|
"common.edit": "Edit",
|
||||||
"common.close": "Close",
|
"common.close": "Close",
|
||||||
|
"common.clear": "Clear",
|
||||||
"common.back": "Back",
|
"common.back": "Back",
|
||||||
"common.next": "Next",
|
"common.next": "Next",
|
||||||
"common.signOut": "Sign out",
|
"common.signOut": "Sign out",
|
||||||
@@ -78,6 +79,9 @@ export const en = {
|
|||||||
"mail.unpin": "Unpin",
|
"mail.unpin": "Unpin",
|
||||||
"mail.delete": "Delete",
|
"mail.delete": "Delete",
|
||||||
"mail.restore": "Restore",
|
"mail.restore": "Restore",
|
||||||
|
"mail.emptyTrash": "Empty trash",
|
||||||
|
"mail.emptySpam": "Empty spam",
|
||||||
|
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
|
||||||
"mail.loadFailed": "Couldn't load this message.",
|
"mail.loadFailed": "Couldn't load this message.",
|
||||||
"mail.imagesBlocked": "Images from this sender are blocked.",
|
"mail.imagesBlocked": "Images from this sender are blocked.",
|
||||||
"mail.loadImages": "Load images",
|
"mail.loadImages": "Load images",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
DiscoverResponse,
|
DiscoverResponse,
|
||||||
DraftInput,
|
DraftInput,
|
||||||
DraftOut,
|
DraftOut,
|
||||||
|
FolderCounts,
|
||||||
MailboxOut,
|
MailboxOut,
|
||||||
QuotaOut,
|
QuotaOut,
|
||||||
RecipientSuggestion,
|
RecipientSuggestion,
|
||||||
@@ -156,6 +157,7 @@ export const api = {
|
|||||||
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
|
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
|
||||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||||
|
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
|
||||||
|
|
||||||
// --- compose ---
|
// --- compose ---
|
||||||
suggestRecipients: (q: string) =>
|
suggestRecipients: (q: string) =>
|
||||||
@@ -196,6 +198,8 @@ export const api = {
|
|||||||
apiFetch<{ status: string }>(`/threads/${id}/restore`, { method: "POST" }),
|
apiFetch<{ status: string }>(`/threads/${id}/restore`, { method: "POST" }),
|
||||||
deleteThreadPermanent: (id: string) =>
|
deleteThreadPermanent: (id: string) =>
|
||||||
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
|
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
|
||||||
|
emptyFolder: (special: string) =>
|
||||||
|
apiFetch<{ status: string }>(`/folders/${special}/empty`, { method: "POST" }),
|
||||||
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
|
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
|
||||||
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
|
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
|
||||||
adoptDraft: (messageId: string) =>
|
adoptDraft: (messageId: string) =>
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ export interface QuotaOut {
|
|||||||
quota_bytes: number;
|
quota_bytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FolderCounts {
|
||||||
|
inbox_unread: number;
|
||||||
|
drafts: number;
|
||||||
|
}
|
||||||
|
|
||||||
// --- M1: compose -------------------------------------------------------------
|
// --- M1: compose -------------------------------------------------------------
|
||||||
|
|
||||||
export interface RecipientSuggestion {
|
export interface RecipientSuggestion {
|
||||||
|
|||||||
@@ -47,16 +47,22 @@ export function connectWs(queryClient: QueryClient): () => void {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
if (typeof payload.thread_id === "string") {
|
if (typeof payload.thread_id === "string") {
|
||||||
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
|
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (name === "flags.changed") {
|
||||||
|
// Read/unread changes affect the Inbox unread badge.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
|
}
|
||||||
if (name === "account.state_changed") {
|
if (name === "account.state_changed") {
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||||
}
|
}
|
||||||
if (name === "message.sent") {
|
if (name === "message.sent") {
|
||||||
// The sent message's draft is deleted server-side; refresh the Drafts list.
|
// The sent message's draft is deleted server-side; refresh the Drafts list.
|
||||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Icon } from "../components/Icon";
|
|||||||
import { MessageList } from "../components/list/MessageList";
|
import { MessageList } from "../components/list/MessageList";
|
||||||
import { Resizer } from "../components/Resizer";
|
import { Resizer } from "../components/Resizer";
|
||||||
import { ThemeToggle } from "../components/ThemeToggle";
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
|
import { EmptyIllustration } from "../components/EmptyIllustration";
|
||||||
import { ThreadView } from "../components/thread/ThreadView";
|
import { ThreadView } from "../components/thread/ThreadView";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import type { MessageKey } from "../i18n/en";
|
import type { MessageKey } from "../i18n/en";
|
||||||
@@ -102,6 +103,7 @@ export function MailLayout() {
|
|||||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||||
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
||||||
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
|
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
|
||||||
|
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
|
||||||
|
|
||||||
const allThreads: ThreadSummary[] = term.trim()
|
const allThreads: ThreadSummary[] = term.trim()
|
||||||
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
|
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
|
||||||
@@ -133,11 +135,19 @@ export function MailLayout() {
|
|||||||
|
|
||||||
const markRead = useMutation({
|
const markRead = useMutation({
|
||||||
mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []),
|
mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["threads"] }),
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
// Auto-mark an opened thread read after a short dwell (4s), not instantly — and
|
||||||
|
// cancel it if you leave before then, so a quick glance doesn't mark it read.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
if (detail && detail.messages.some((m) => m.is_unread)) markRead.mutate(detail.id);
|
if (!detail || !detail.messages.some((m) => m.is_unread)) return;
|
||||||
|
const id = detail.id;
|
||||||
|
const timer = window.setTimeout(() => markRead.mutate(id), 4000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [detailQuery.data?.id]);
|
}, [detailQuery.data?.id]);
|
||||||
|
|
||||||
@@ -154,6 +164,7 @@ export function MailLayout() {
|
|||||||
setComposerExpanded(false);
|
setComposerExpanded(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Leaving a folder closes any open composer/draft editor: the reading pane
|
// Leaving a folder closes any open composer/draft editor: the reading pane
|
||||||
@@ -171,6 +182,7 @@ export function MailLayout() {
|
|||||||
const afterMove = () => {
|
const afterMove = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
navigate("/mail");
|
navigate("/mail");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,7 +196,7 @@ export function MailLayout() {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
for (const key of ["threads", "drafts", "drafts-remote", "quota"]) {
|
for (const key of ["threads", "drafts", "drafts-remote", "quota", "folder-counts"]) {
|
||||||
queryClient.invalidateQueries({ queryKey: [key] });
|
queryClient.invalidateQueries({ queryKey: [key] });
|
||||||
}
|
}
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -227,6 +239,55 @@ export function MailLayout() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
|
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Per-thread actions for the mail-list hover buttons (act on a given id, not the
|
||||||
|
// currently-open thread). Close the reading pane only if the acted thread is open.
|
||||||
|
const listAfterMove = (id: string) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
|
if (id === threadId) navigate("/mail");
|
||||||
|
};
|
||||||
|
const listArchive = async (id: string) => {
|
||||||
|
await api.archiveThread(id);
|
||||||
|
listAfterMove(id);
|
||||||
|
};
|
||||||
|
const listTrash = async (id: string) => {
|
||||||
|
await api.trashThread(id);
|
||||||
|
listAfterMove(id);
|
||||||
|
};
|
||||||
|
const listRestore = async (id: string) => {
|
||||||
|
await api.restoreThread(id);
|
||||||
|
listAfterMove(id);
|
||||||
|
};
|
||||||
|
const listDeletePermanent = async (id: string) => {
|
||||||
|
await api.deleteThreadPermanent(id);
|
||||||
|
listAfterMove(id);
|
||||||
|
};
|
||||||
|
const listTogglePin = async (id: string, pinned: boolean) => {
|
||||||
|
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["thread", 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);
|
||||||
|
const emptyCurrentFolder = async () => {
|
||||||
|
if (!window.confirm(t("mail.emptyConfirm"))) return;
|
||||||
|
setEmptying(true);
|
||||||
|
try {
|
||||||
|
await api.emptyFolder(currentFolder);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
navigate("/mail"); // an open thread from this folder is about to be deleted
|
||||||
|
window.setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||||
|
setEmptying(false);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
const openRelative = (delta: number) => {
|
const openRelative = (delta: number) => {
|
||||||
const list = threadsRef.current;
|
const list = threadsRef.current;
|
||||||
if (list.length === 0) return;
|
if (list.length === 0) return;
|
||||||
@@ -293,7 +354,14 @@ export function MailLayout() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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-sm">
|
||||||
{FOLDERS.map((f) => (
|
{FOLDERS.map((f) => {
|
||||||
|
const badge =
|
||||||
|
f.view === "folder:inbox"
|
||||||
|
? countsQuery.data?.inbox_unread
|
||||||
|
: f.view === "folder:drafts"
|
||||||
|
? countsQuery.data?.drafts
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={f.view}
|
key={f.view}
|
||||||
active={view === f.view}
|
active={view === f.view}
|
||||||
@@ -304,10 +372,14 @@ export function MailLayout() {
|
|||||||
navigate("/mail");
|
navigate("/mail");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name={f.icon} size={18} />
|
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
|
||||||
{!collapsed && t(f.label)}
|
{!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>
|
||||||
|
) : null}
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{/* Per-account views are hidden entirely when collapsed. */}
|
{/* Per-account views are hidden entirely when collapsed. */}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<>
|
<>
|
||||||
@@ -335,7 +407,7 @@ export function MailLayout() {
|
|||||||
title={collapsed ? t("nav.settings") : undefined}
|
title={collapsed ? t("nav.settings") : undefined}
|
||||||
to="/settings"
|
to="/settings"
|
||||||
>
|
>
|
||||||
<Icon name="settings" size={16} />
|
<Icon name="settings" size={22} />
|
||||||
{!collapsed && t("nav.settings")}
|
{!collapsed && t("nav.settings")}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
@@ -371,13 +443,31 @@ export function MailLayout() {
|
|||||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
|
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
|
||||||
>
|
>
|
||||||
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
|
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
|
||||||
<input
|
<div className="relative w-full">
|
||||||
ref={searchRef}
|
<input
|
||||||
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-sm text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
ref={searchRef}
|
||||||
placeholder={t("nav.search")}
|
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"
|
||||||
value={term}
|
placeholder={t("nav.search")}
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
value={term}
|
||||||
/>
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
{term && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("common.clear")}
|
||||||
|
aria-label={t("common.clear")}
|
||||||
|
onClick={() => {
|
||||||
|
setTerm("");
|
||||||
|
searchRef.current?.focus();
|
||||||
|
}}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||||
|
<path d="M4 4l8 8M12 4l-8 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isDrafts && (
|
{!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-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-sm">
|
||||||
@@ -390,14 +480,25 @@ export function MailLayout() {
|
|||||||
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
|
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
|
||||||
{t("filter.pinned")}
|
{t("filter.pinned")}
|
||||||
</FilterTab>
|
</FilterTab>
|
||||||
<button
|
<div className="-mr-1.5 ml-auto flex items-center gap-1">
|
||||||
className="-mr-1.5 ml-auto rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
{(currentFolder === "trash" || currentFolder === "junk") && (
|
||||||
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
|
<button
|
||||||
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
|
className="rounded px-2 py-1 text-xs font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
|
||||||
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
|
onClick={emptyCurrentFolder}
|
||||||
>
|
disabled={emptying}
|
||||||
<SortIcon />
|
>
|
||||||
</button>
|
{currentFolder === "trash" ? t("mail.emptyTrash") : t("mail.emptySpam")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
|
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
|
||||||
|
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
|
||||||
|
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
|
||||||
|
>
|
||||||
|
<SortIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
@@ -415,7 +516,17 @@ export function MailLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MessageList threads={pageThreads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
|
<MessageList
|
||||||
|
threads={pageThreads}
|
||||||
|
selectedId={threadId}
|
||||||
|
folder={currentFolder}
|
||||||
|
onSelect={(id) => navigate(`/mail/t/${id}`)}
|
||||||
|
onArchive={listArchive}
|
||||||
|
onRestore={listRestore}
|
||||||
|
onTrash={listTrash}
|
||||||
|
onDeletePermanent={listDeletePermanent}
|
||||||
|
onTogglePin={listTogglePin}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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-xs text-app-muted">
|
||||||
@@ -519,8 +630,9 @@ export function MailLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-app-faint">
|
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
|
||||||
{t("mail.selectConversation")}
|
<EmptyIllustration className="w-56 max-w-[55%]" />
|
||||||
|
<span className="text-3xl">{t("mail.selectConversation")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 63 KiB |