feat(mail): complete mailbox display states

This commit is contained in:
2026-08-19 23:03:46 +02:00
parent e0e00d7000
commit 9ac8847559
9 changed files with 130 additions and 9 deletions
+34 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { Activity, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { ToolbarGroup, ActionToolbar,
ActionBlockerHint,
Button,
@@ -29,6 +29,7 @@ import {
type MailServerProfile } from
"../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
const MAILBOX_DOCUMENTATION = {
topicId: "mail.workflow.read-mailbox",
@@ -45,6 +46,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
const [messagePage, setMessagePage] = useState(1);
const [messagePageSize, setMessagePageSize] = useState(10);
const [messageQuery, setMessageQuery] = useState("");
@@ -77,6 +79,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const syncState = mailboxSyncState(messageProvenance);
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
@@ -162,6 +165,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
@@ -188,6 +192,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setLoadingMessages(true);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
@@ -224,6 +229,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages);
setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(response.messages));
} catch (err) {
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
const message = errorText(err);
@@ -235,6 +241,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
@@ -252,6 +259,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
setLoadingMessages(true);
setMessageProvenance(null);
setMessageError("");
setDetailError("");
setError("");
@@ -264,6 +272,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
setMessages(loaded);
setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(response));
setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
@@ -278,6 +287,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setError(message);
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
@@ -324,6 +334,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
@@ -476,6 +487,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<span>{messageCountLabel}</span>
<span>{selectedFolder}</span>
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
{syncState &&
<span className={`mailbox-sync-provenance is-${syncState}`} aria-live="polite">
<Database size={13} aria-hidden="true" />
{syncState === "refreshing" ? "i18n:govoplan-mail.cached_index_refreshing.75f18a6c" : syncState === "cached" ? "i18n:govoplan-mail.cached_mailbox_index.16fe75d1" : "i18n:govoplan-mail.live_provider_response.39c46538"}
{messageProvenance?.indexedAt && <span> · {formatDateTime(messageProvenance.indexedAt, { fallback: "-" })}</span>}
</span>
}
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
</div>
@@ -494,10 +512,11 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
const selected = key === selectedMessageKey;
const loadingSelected = loadingMessage && key === pendingMessageKey;
const read = isMailboxMessageRead(message.flags);
return (
<div
key={message.uid}
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
className={`file-list-row file-row mailbox-message-row ${read ? "is-read" : "is-unread"} ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
role="row"
tabIndex={0}
onClick={() => void openMessage(message)}
@@ -510,7 +529,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<div className="file-list-name-cell">
<div className="file-list-name">
<Mail className="file-row-icon" size={20} aria-hidden="true" />
{read ? <MailOpen className="file-row-icon" size={20} aria-hidden="true" /> : <Mail className="file-row-icon" size={20} aria-hidden="true" />}
<span>
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
<small>{message.from_header || "-"}</small>
@@ -519,6 +538,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</div>
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
<span className="file-row-tail mailbox-message-tail">
<span className="mailbox-read-state" title={read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}>
<span className="visually-hidden">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
<span aria-hidden="true">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
</span>
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
<span>{formatBytes(message.size_bytes)}</span>
</span>
@@ -584,6 +607,14 @@ function mailboxCursorKey(profileId: string, folder: string, pageSize: number):
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
}
function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance {
return {
fromCache: response.from_cache === true,
refreshing: response.refreshing === true,
indexedAt: response.indexed_at ?? null
};
}
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return messages;
+17
View File
@@ -0,0 +1,17 @@
export type MailboxSyncProvenance = {
fromCache: boolean;
refreshing: boolean;
indexedAt: string | null;
};
export type MailboxSyncState = "live" | "cached" | "refreshing";
export function isMailboxMessageRead(flags: readonly string[] | null | undefined): boolean {
return (flags ?? []).some((flag) => flag.trim().replace(/^\\+/, "").toLocaleLowerCase() === "seen");
}
export function mailboxSyncState(provenance: MailboxSyncProvenance | null): MailboxSyncState | null {
if (!provenance) return null;
if (provenance.refreshing) return "refreshing";
return provenance.fromCache ? "cached" : "live";
}
+29
View File
@@ -560,6 +560,14 @@
opacity: .72;
}
.mailbox-message-row.is-read .file-list-name strong {
font-weight: 600;
}
.mailbox-message-row.is-unread .file-list-name strong {
font-weight: 850;
}
.mailbox-message-row .file-list-name strong {
max-width: 100%;
}
@@ -583,6 +591,27 @@
gap: 4px;
}
.mailbox-read-state,
.mailbox-sync-provenance {
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.mailbox-message-row.is-unread .mailbox-read-state {
color: var(--accent-text);
}
.mailbox-sync-provenance {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mailbox-sync-provenance.is-refreshing {
color: var(--warning-text);
}
.mailbox-preview-panel {
display: flex;
flex-direction: column;