Prepare v0.1.0 release

This commit is contained in:
2026-06-24 20:00:38 +02:00
parent a9d16a2c89
commit 97015e5a01
10 changed files with 409 additions and 79 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
import {
Button,
@@ -33,25 +33,38 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
const [selectedFolder, setSelectedFolder] = useState("INBOX");
const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
const [pendingMessageKey, setPendingMessageKey] = useState("");
const [loadingProfiles, setLoadingProfiles] = useState(true);
const [loadingFolders, setLoadingFolders] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(false);
const [error, setError] = useState("");
const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0);
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
const busy = loadingProfiles || loadingFolders || loadingMessages || loadingMessage;
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : pendingMessageKey;
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const folderEmptyText = noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.";
const messageEmptyText = !selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : "No messages in this folder.";
const previewEmptyText = loadingMessage ? "Loading message..." : "Select a message to inspect its content.";
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
useEffect(() => { if (selectedProfileId && selectedFolder) void loadMessages(selectedProfileId, selectedFolder); }, [selectedProfileId, selectedFolder]);
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder); }, [foldersReady, selectedProfileId, selectedFolder]);
useEffect(() => {
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
@@ -73,8 +86,11 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
if (usable.length === 0) {
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setPendingMessageKey("");
}
} catch (err) {
setError(errorText(err));
@@ -85,71 +101,112 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
async function loadFolders(profileId = selectedProfileId) {
if (!profileId) return;
const requestId = ++folderRequestRef.current;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
setLoadingFolders(true);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setPendingMessageKey("");
setError("");
try {
const response = await listMailboxFolders(settings, profileId);
if (requestId !== folderRequestRef.current) return;
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded.");
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
setFolders(loaded);
setExpandedFolders(new Set());
setSelectedFolder((current) => {
if (current && loaded.some((folder) => folder.name === current)) return current;
if (loaded.some((folder) => folder.name === "INBOX")) return "INBOX";
return response.detected_sent_folder || loaded[0]?.name || "INBOX";
});
let nextFolder = selectedFolder;
if (!nextFolder || !loaded.some((folder) => folder.name === nextFolder)) {
nextFolder = loaded.some((folder) => folder.name === "INBOX") ? "INBOX" : response.detected_sent_folder || loaded[0]?.name || "INBOX";
}
setSelectedFolder(nextFolder);
setFoldersLoadedForProfile(profileId);
} catch (err) {
if (requestId !== folderRequestRef.current) return;
setError(errorText(err));
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setPendingMessageKey("");
} finally {
setLoadingFolders(false);
if (requestId === folderRequestRef.current) setLoadingFolders(false);
}
}
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1;
setLoadingMessages(true);
setMessageTotalCount(null);
setSelectedMessage(null);
setPendingMessageKey("");
setError("");
try {
const response = await listMailboxMessages(settings, profileId, folder, 50);
setMessages(response.messages ?? []);
if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? [];
setMessages(loaded);
setMessageTotalCount(response.total_count ?? loaded.length);
setSelectedMessage(null);
} catch (err) {
if (requestId !== messageListRequestRef.current) return;
setError(errorText(err));
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setPendingMessageKey("");
} finally {
setLoadingMessages(false);
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
}
}
async function openMessage(message: MailMailboxMessageSummary) {
if (!selectedProfileId || loadingMessage) return;
if (!selectedProfileId) return;
const folderName = message.folder || selectedFolder;
const requestId = ++messageDetailRequestRef.current;
setPendingMessageKey(mailboxMessageKey(folderName, message.uid));
setSelectedMessage(null);
setLoadingMessage(true);
setError("");
try {
const response = await getMailboxMessage(settings, selectedProfileId, selectedFolder, message.uid);
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
if (requestId !== messageDetailRequestRef.current) return;
setSelectedMessage(response.message);
setPendingMessageKey("");
} catch (err) {
if (requestId !== messageDetailRequestRef.current) return;
setError(errorText(err));
setPendingMessageKey("");
} finally {
setLoadingMessage(false);
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
}
}
function selectProfile(profileId: string) {
folderRequestRef.current += 1;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
setSelectedProfileId(profileId);
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setPendingMessageKey("");
setExpandedFolders(new Set());
setLoadingFolders(false);
setLoadingMessages(false);
setLoadingMessage(false);
}
function openFolderNode(node: MailFolderNode) {
if (node.folderName) {
setSelectedFolder(node.folderName);
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName);
else setSelectedFolder(node.folderName);
return;
}
toggleFolderNode(node);
@@ -168,11 +225,11 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className={`file-manager-shell mailbox-shell ${busy ? "is-loading" : ""}`}>
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="Mailbox folders">
<div className="file-tree-heading">Folders</div>
<div className="file-tree-list">
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">No folders available.</p>}
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>}
<ExplorerTree
nodes={folderTree}
getNodeId={(node) => node.id}
@@ -182,13 +239,20 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
expandedIds={expandedFolders}
onOpen={openFolderNode}
onToggle={toggleFolderNode}
disabled={busy}
renderNodeContent={(node) => (
<span className="mailbox-tree-node-label">
<span>{node.label}</span>
{node.flags.length > 0 && <small>{node.flags.join(" ")}</small>}
</span>
)}
disabled={shellBusy}
renderNodeContent={(node) => {
const flagText = folderFlagLabel(node.flags);
const showCount = foldersReady && node.folderName === selectedFolder && messageTotalCount !== null;
return (
<span className="mailbox-tree-node-label">
<span className="mailbox-tree-node-main">
<span>{node.label}</span>
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>}
</span>
{flagText && <small>{flagText}</small>}
</span>
);
}}
/>
</div>
</aside>
@@ -197,16 +261,27 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-list-sticky">
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
<label className="mailbox-profile-field">
<span>Profile</span>
<select value={selectedProfileId} disabled={busy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
<span>IMAP profile</span>
<select value={selectedProfileId} disabled={shellBusy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>}
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
<Button onClick={() => void loadProfiles()} disabled={busy}>
<RefreshCw size={16} aria-hidden="true" />
Reload
</Button>
<div className="mailbox-toolbar-actions">
<Button onClick={() => void loadProfiles()} disabled={shellBusy} title="Reload IMAP profiles">
<RefreshCw size={16} aria-hidden="true" />
Profiles
</Button>
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || shellBusy} title="Refresh mailbox folders">
<RefreshCw size={16} aria-hidden="true" />
Folders
</Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || shellBusy} title="Refresh messages in the current folder">
<RefreshCw size={16} aria-hidden="true" />
Messages
</Button>
</div>
</div>
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
@@ -215,9 +290,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
</nav>
<div className="file-list-meta">
<span>{messages.length} message{messages.length === 1 ? "" : "s"}</span>
<span>{messageCountLabel}</span>
<span>{selectedFolder}</span>
{busy && <span>Working...</span>}
{shellBusy && <span>Working...</span>}
{loadingMessage && <span>Loading preview...</span>}
</div>
<div className="file-list-table-head mailbox-message-head" role="row">
@@ -229,13 +305,15 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-list-drop-target mailbox-message-scroll">
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
{messages.length === 0 && <div className="file-list-empty">No messages in this folder.</div>}
{messages.length === 0 && <div className="file-list-empty">{messageEmptyText}</div>}
{messages.map((message) => {
const selected = message.uid === selectedMessage?.uid;
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
const selected = key === selectedMessageKey;
const loadingSelected = loadingMessage && key === pendingMessageKey;
return (
<div
key={message.uid}
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""}`}
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
role="row"
tabIndex={0}
onClick={() => void openMessage(message)}
@@ -287,12 +365,12 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
contentType: attachment.content_type,
sizeBytes: attachment.size_bytes
}))}
emptyText="Select a message to inspect its content."
emptyText={previewEmptyText}
/>
</div>
</section>
{busy && (
{shellBusy && (
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div>
</div>
@@ -372,6 +450,48 @@ function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents:
return [];
}
function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`;
}
function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
if (!ready) return "No folder loaded";
if (loading) return "Loading messages";
if (totalCount !== null && totalCount > visibleCount) return `${visibleCount} of ${totalCount} messages`;
const count = totalCount ?? visibleCount;
return `${count} message${count === 1 ? "" : "s"}`;
}
function folderFlagLabel(flags: string[]): string {
const labels = flags.map(displayFolderFlag).filter((label): label is string => Boolean(label));
return Array.from(new Set(labels)).join(" ");
}
function displayFolderFlag(flag: string): string | null {
const normalized = flag.replace(/^\\/, "").toLowerCase();
switch (normalized) {
case "sent":
return "Sent";
case "drafts":
return "Drafts";
case "trash":
return "Trash";
case "archive":
return "Archive";
case "junk":
return "Junk";
case "flagged":
return "Flagged";
case "haschildren":
case "hasnochildren":
case "noselect":
return null;
default:
return null;
}
}
function transportLabel(profile: MailServerProfile): string {
const imap = profile.imap;
if (!imap?.host) return "IMAP not configured";