Files
govoplan-postbox/webui/src/features/postbox/PostboxPage.tsx
T

1267 lines
43 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Archive,
Building2,
CheckCheck,
Inbox,
Layers3,
MailOpen,
Paperclip,
Pencil,
Plus,
RefreshCw,
Reply,
Search,
Send,
Trash2,
UserRoundCheck,
X
} from "lucide-react";
import {
ActionBlockerHint,
Button,
ConfirmDialog,
DataGridPaginationBar,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
IconButton,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
hasScope,
i18nMessage,
isApiError,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
createPostboxGrouping,
createPostboxMessage,
deletePostboxGrouping,
getPostboxMessage,
listPostboxGroupings,
listPostboxMessages,
listPostboxes,
markPostboxMessage,
replyToPostboxMessage,
updatePostboxGrouping,
type PostboxDirectoryItem,
type PostboxGrouping,
type PostboxMessage
} from "../../api/postbox";
import {
POSTBOX_DOCUMENTATION,
POSTBOX_FIELD_DOCUMENTATION,
POSTBOX_INTERFACE_I18N,
postboxBusyReason
} from "./interfacePatterns";
type GroupingDraft = {
id: string;
name: string;
is_default: boolean;
postbox_ids: string[];
};
type MessageStateFilter = "all" | "unread" | "read" | "acknowledged";
type MessageDraft = {
postbox_id: string;
subject: string;
body_text: string;
classification: string;
recipients: string;
};
const emptyGrouping = (): GroupingDraft => ({
id: "",
name: "",
is_default: false,
postbox_ids: []
});
const emptyMessageDraft = (): MessageDraft => ({
postbox_id: "",
subject: "",
body_text: "",
classification: "internal",
recipients: ""
});
export default function PostboxPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const requestedMessageId = useRef(
new URLSearchParams(window.location.search).get("message") ?? ""
);
const requestedMessageLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
const [selectedScope, setSelectedScope] = useState("all");
const [selectedPostboxId, setSelectedPostboxId] = useState("");
const [messages, setMessages] = useState<PostboxMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState("");
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
const [unavailableSelection, setUnavailableSelection] = useState("");
const [total, setTotal] = useState(0);
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
const [searchDraft, setSearchDraft] = useState("");
const [messageQuery, setMessageQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [loadingDirectory, setLoadingDirectory] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
const [groupingBaseline, setGroupingBaseline] = useState<GroupingDraft>(emptyGrouping);
const [deleteGroupingTarget, setDeleteGroupingTarget] = useState<GroupingDraft | null>(null);
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
const [replyParent, setReplyParent] = useState<PostboxMessage | null>(null);
const [messageDraft, setMessageDraft] = useState<MessageDraft>(emptyMessageDraft);
const [messageBaseline, setMessageBaseline] = useState<MessageDraft>(emptyMessageDraft);
const { language } = usePlatformLanguage();
const { requestDiscard } = useUnsavedChanges();
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
const canSend = hasScope(auth, "postbox:message:write");
const canReply = hasScope(auth, "postbox:message:reply");
const groupingDirty = groupingDialogOpen && draftKey(groupingDraft) !== draftKey(groupingBaseline);
const messageDirty = messageDialogOpen && draftKey(messageDraft) !== draftKey(messageBaseline);
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
[postboxes, selectedPostboxId]
);
const selectedGrouping = useMemo(
() => groupings.find((grouping) => grouping.id === selectedScope) ?? null,
[groupings, selectedScope]
);
const scopePostboxIds = useMemo(() => {
if (selectedPostboxId) return [selectedPostboxId];
if (selectedGrouping) {
const visible = new Set(postboxes.map((postbox) => postbox.id));
return selectedGrouping.postbox_ids.filter((postboxId) => visible.has(postboxId));
}
return postboxes.map((postbox) => postbox.id);
}, [postboxes, selectedGrouping, selectedPostboxId]);
const scopeKey = scopePostboxIds.join("|");
const composeDisabledReason = postboxBusyReason(false, busy)
?? (!canSend ? POSTBOX_INTERFACE_I18N.noSendReason : undefined)
?? (!postboxes.length ? POSTBOX_INTERFACE_I18N.noPostbox : undefined);
const replyDisabledReason = postboxBusyReason(false, busy)
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined);
const acknowledgeDisabledReason = postboxBusyReason(false, busy)
?? (!canAcknowledge ? POSTBOX_INTERFACE_I18N.noAcknowledgeReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined)
?? (selectedMessage?.acknowledged_at
? POSTBOX_INTERFACE_I18N.alreadyAcknowledged
: undefined);
useUnsavedDraftGuard({
dirty: groupingDirty || messageDirty,
title: "Unsaved Postbox draft",
message: "Save or discard the open Postbox draft before leaving this surface.",
onSave: () => groupingDirty ? saveGrouping() : submitMessage(),
onDiscard: discardOpenDraft
});
const loadDirectory = useCallback(async () => {
setLoadingDirectory(true);
setError("");
try {
const [nextPostboxes, nextGroupings] = await Promise.all([
listPostboxes(settings),
listPostboxGroupings(settings)
]);
setPostboxes(nextPostboxes);
setGroupings(nextGroupings);
setSelectedScope((current) => {
if (current === "all" || nextGroupings.some((grouping) => grouping.id === current)) {
return current;
}
return nextGroupings.find((grouping) => grouping.is_default)?.id ?? "all";
});
setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current)
? current
: ""
);
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoadingDirectory(false);
}
}, [settings]);
const loadMessages = useCallback(async () => {
if (!scopePostboxIds.length) {
setMessages([]);
setSelectedMessageId("");
setSelectedMessage(null);
setUnavailableSelection("");
setTotal(0);
return;
}
setLoadingMessages(true);
setError("");
try {
const response = await listPostboxMessages(
settings,
scopePostboxIds,
pageSize,
(page - 1) * pageSize,
messageQuery,
messageState
);
setMessages(response.messages);
setTotal(response.total);
setSelectedMessageId((current) =>
current &&
(
response.messages.some((message) => message.id === current) ||
current === requestedMessageId.current
)
? current
: ""
);
if (!response.messages.length) setSelectedMessage(null);
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoadingMessages(false);
}
}, [messageQuery, messageState, page, pageSize, scopeKey, settings]);
useEffect(() => {
void loadDirectory();
}, [loadDirectory]);
useEffect(() => {
void loadMessages();
}, [loadMessages]);
useEffect(() => {
const messageId = requestedMessageId.current;
if (
requestedMessageLoaded.current ||
!messageId ||
loadingDirectory ||
!postboxes.length
) {
return;
}
requestedMessageLoaded.current = true;
let cancelled = false;
void (async () => {
try {
const message = await getPostboxMessage(settings, messageId);
if (cancelled) return;
if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) {
setSelectedMessageId(messageId);
setSelectedMessage(null);
setUnavailableSelection(
"This message is outside your current Postbox assignments."
);
return;
}
setSelectedScope("all");
setSelectedPostboxId(message.postbox_id);
setSelectedMessageId(message.id);
setSelectedMessage(message);
setUnavailableSelection("");
} catch (loadError) {
if (!cancelled && isApiError(loadError, 403, 404)) {
setSelectedMessageId(messageId);
setSelectedMessage(null);
setUnavailableSelection(
"This message is no longer available or is outside your current Postbox assignments."
);
} else if (!cancelled) {
setError(errorMessage(loadError));
}
}
})();
return () => {
cancelled = true;
};
}, [loadingDirectory, postboxes, settings]);
useEffect(() => {
if (!selectedMessageId) return;
let cancelled = false;
setUnavailableSelection("");
void (async () => {
try {
let message = await getPostboxMessage(settings, selectedMessageId);
if (message.availability === "available" && !message.read_at) {
message = await markPostboxMessage(settings, selectedMessageId, "read");
}
if (cancelled) return;
setSelectedMessage(message);
setMessages((items) =>
items.map((item) => (item.id === message.id ? message : item))
);
} catch (loadError) {
if (!cancelled && isApiError(loadError, 403, 404)) {
setSelectedMessage(null);
setUnavailableSelection(
"This message is no longer available or is outside your current Postbox assignments."
);
} else if (!cancelled) {
setError(errorMessage(loadError));
}
}
})();
return () => {
cancelled = true;
};
}, [selectedMessageId, settings]);
async function acknowledgeSelected() {
if (
!selectedMessage ||
selectedMessage.availability !== "available" ||
!canAcknowledge
) {
return;
}
setBusy(true);
setError("");
try {
const next = await markPostboxMessage(
settings,
selectedMessage.id,
"acknowledged"
);
setSelectedMessage(next);
setMessages((items) =>
items.map((item) => (item.id === next.id ? next : item))
);
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function selectPostbox(postboxId: string) {
setSelectedPostboxId(postboxId);
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
setUnavailableSelection("");
}
function selectScope(scopeId: string) {
setSelectedScope(scopeId);
setSelectedPostboxId("");
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
setUnavailableSelection("");
}
function openNewGrouping() {
const next = emptyGrouping();
setGroupingDraft(next);
setGroupingBaseline(next);
setGroupingDialogOpen(true);
}
function openGrouping(grouping: PostboxGrouping) {
const next = {
id: grouping.id,
name: grouping.name,
is_default: grouping.is_default,
postbox_ids: [...grouping.postbox_ids]
};
setGroupingDraft(next);
setGroupingBaseline(next);
setGroupingDialogOpen(true);
}
async function saveGrouping(): Promise<boolean> {
if (!groupingDraft.name.trim()) return false;
setBusy(true);
setError("");
const payload = {
name: groupingDraft.name.trim(),
is_default: groupingDraft.is_default,
postbox_ids: groupingDraft.postbox_ids
};
try {
const existing = groupings.find((item) => item.id === groupingDraft.id);
const saved = existing
? await updatePostboxGrouping(settings, existing, payload)
: await createPostboxGrouping(settings, payload);
await loadDirectory();
setSelectedScope(saved.id);
setSelectedPostboxId("");
setGroupingDialogOpen(false);
setGroupingBaseline(groupingDraft);
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
async function removeGrouping() {
if (!deleteGroupingTarget?.id) return;
setBusy(true);
setError("");
try {
const existing = groupings.find((item) => item.id === deleteGroupingTarget.id);
if (!existing) throw new Error("The grouping is no longer available.");
await deletePostboxGrouping(settings, existing);
setSelectedScope("all");
setSelectedPostboxId("");
setGroupingDialogOpen(false);
setDeleteGroupingTarget(null);
await loadDirectory();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null;
if (!postbox) return;
setReplyParent(null);
const next = {
...emptyMessageDraft(),
postbox_id: postbox.id,
classification: postbox.classification
};
setMessageDraft(next);
setMessageBaseline(next);
setMessageDialogOpen(true);
}
function openReply() {
if (!selectedMessage) return;
setReplyParent(selectedMessage);
const next = {
...emptyMessageDraft(),
postbox_id: selectedMessage.postbox_id,
subject: selectedMessage.subject.toLowerCase().startsWith("re:")
? selectedMessage.subject
: `Re: ${selectedMessage.subject}`,
classification: selectedMessage.classification
};
setMessageDraft(next);
setMessageBaseline(next);
setMessageDialogOpen(true);
}
async function submitMessage(): Promise<boolean> {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return false;
setBusy(true);
setError("");
const participants = messageDraft.recipients
.split(/[;,\n]/)
.map((address) => address.trim())
.filter(Boolean)
.map((address) => ({
kind: "to",
reference_type: "address",
address
}));
const payload = {
idempotency_key: crypto.randomUUID(),
subject: messageDraft.subject.trim(),
body_text: messageDraft.body_text || null,
classification: messageDraft.classification,
participants,
attachments: [],
metadata: {}
};
try {
const message = replyParent
? await replyToPostboxMessage(settings, replyParent.id, payload)
: await createPostboxMessage(
settings,
messageDraft.postbox_id,
payload
);
setMessageDialogOpen(false);
setReplyParent(null);
setSelectedPostboxId(message.postbox_id);
setSelectedScope("all");
setSelectedMessageId(message.id);
setSelectedMessage(message);
setPage(1);
const refreshed = await listPostboxMessages(
settings,
[message.postbox_id],
pageSize,
0,
messageQuery,
messageState
);
setMessages(refreshed.messages);
setTotal(refreshed.total);
setMessageBaseline(messageDraft);
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
function discardOpenDraft() {
if (groupingDialogOpen) {
setGroupingDraft(groupingBaseline);
setGroupingDialogOpen(false);
}
if (messageDialogOpen) {
setMessageDraft(messageBaseline);
setMessageDialogOpen(false);
setReplyParent(null);
}
}
function closeGroupingDialog() {
const close = () => {
setGroupingDraft(groupingBaseline);
setGroupingDialogOpen(false);
};
if (groupingDirty) requestDiscard(close);
else close();
}
function closeMessageDialog() {
const close = () => {
setMessageDraft(messageBaseline);
setMessageDialogOpen(false);
setReplyParent(null);
};
if (messageDirty) requestDiscard(close);
else close();
}
return (
<main className="workspace-data-page module-entry-page postbox-page">
<div className="postbox-shell">
<aside className="postbox-directory" data-view-surface="postbox.inbox.directory">
<div className="postbox-bar">
<div className="postbox-bar-title">
<Inbox size={17} aria-hidden="true" />
<strong>Postbox</strong>
<DocumentationHelpLink reference={POSTBOX_DOCUMENTATION} />
</div>
<div className="postbox-icon-actions">
<IconButton
label="New unified view"
icon={<Plus size={16} />}
onClick={openNewGrouping}
disabled={busy}
disabledReason={postboxBusyReason(false, busy)}
/>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => requestDiscard(() => void loadDirectory())}
disabled={loadingDirectory || busy}
disabledReason={postboxBusyReason(loadingDirectory, busy)}
/>
</div>
</div>
<div className="postbox-scope-control">
<label htmlFor="postbox-scope">Inbox view</label>
<div className="postbox-scope-row">
<select
id="postbox-scope"
value={selectedScope}
onChange={(event) => selectScope(event.target.value)}
>
<option value="all">All postboxes</option>
{groupings.map((grouping) => (
<option key={grouping.id} value={grouping.id}>
{grouping.name}
{grouping.unread_count ? ` (${grouping.unread_count})` : ""}
{grouping.is_default ? " · default" : ""}
</option>
))}
</select>
{selectedGrouping ? (
<IconButton
label="Edit unified view"
icon={<Pencil size={15} />}
onClick={() => openGrouping(selectedGrouping)}
/>
) : null}
</div>
</div>
<div className="postbox-directory-list">
{loadingDirectory ? <p className="postbox-note">Loading postboxes</p> : null}
{!loadingDirectory && !postboxes.length ? (
<div className="postbox-empty compact">
<Archive size={20} />
<strong>No assigned postboxes</strong>
<p>Postboxes appear when your account has a current matching function assignment.</p>
<ActionBlockerHint
reason={{
summary: POSTBOX_INTERFACE_I18N.noPostbox,
requiredAction: POSTBOX_INTERFACE_I18N.assignmentAction,
actor: POSTBOX_INTERFACE_I18N.assignmentActor,
target: POSTBOX_INTERFACE_I18N.assignmentDestination
}}
labels={{
requiredAction: POSTBOX_INTERFACE_I18N.requiredAction,
actor: POSTBOX_INTERFACE_I18N.actor,
target: POSTBOX_INTERFACE_I18N.destination
}}
documentation={POSTBOX_DOCUMENTATION}
/>
</div>
) : null}
{postboxes.length ? (
<SelectionList label="Assigned postboxes">
{postboxes.map((postbox) => (
<SelectionListItem
key={postbox.id}
selected={selectedPostboxId === postbox.id}
className="postbox-directory-item"
onClick={() => selectPostbox(postbox.id)}
>
<span className="postbox-item-title">
<strong>{postbox.name}</strong>
{postbox.vacant ? (
<StatusBadge status="warning" label="Vacant" />
) : null}
</span>
<span className="postbox-item-context">
{postbox.organization_unit_name || "No organization"} ·{" "}
{postbox.function_name || "No function"}
</span>
<span className="postbox-item-address">{postbox.address}</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</div>
</aside>
<section className="postbox-message-list" data-view-surface="postbox.inbox.messages">
<div className="postbox-bar">
<div className="postbox-bar-title">
{selectedPostbox ? (
<>
<Building2 size={17} aria-hidden="true" />
<strong>{selectedPostbox.name}</strong>
</>
) : selectedGrouping ? (
<>
<Layers3 size={17} aria-hidden="true" />
<strong>{selectedGrouping.name}</strong>
</>
) : (
<>
<Layers3 size={17} aria-hidden="true" />
<strong>All postboxes</strong>
</>
)}
<span className="postbox-total">{total}</span>
</div>
<div className="postbox-icon-actions">
<IconButton
label="New message"
icon={<Send size={16} />}
onClick={openCompose}
disabled={Boolean(composeDisabledReason)}
disabledReason={composeDisabledReason}
/>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => requestDiscard(() => void loadMessages())}
disabled={loadingMessages || busy}
disabledReason={postboxBusyReason(loadingMessages, busy)}
/>
</div>
</div>
<div className="postbox-message-filters">
<div className="postbox-search-row">
<input
type="search"
value={searchDraft}
placeholder="Search messages"
aria-label="Search Postbox messages"
onChange={(event) => setSearchDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
setMessageQuery(searchDraft.trim());
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
{searchDraft || messageQuery ? (
<IconButton
label="Clear message search"
icon={<X size={15} />}
onClick={() => {
setSearchDraft("");
setMessageQuery("");
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
) : null}
<IconButton
label="Search messages"
icon={<Search size={15} />}
onClick={() => {
setMessageQuery(searchDraft.trim());
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
</div>
<SegmentedControl<MessageStateFilter>
ariaLabel="Message state"
width="fill"
options={[
{ id: "all", label: "All" },
{ id: "unread", label: "Unread" },
{ id: "read", label: "Read" },
{ id: "acknowledged", label: "Acknowledged" }
]}
value={messageState}
onChange={(next) => {
setMessageState(next);
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
</div>
{error ? (
<DismissibleAlert tone="danger" compact resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<div className="postbox-messages">
{loadingMessages ? <p className="postbox-note">Loading messages</p> : null}
{!loadingMessages && !messages.length ? (
<div className="postbox-empty">
<MailOpen size={24} />
<strong>No messages</strong>
<p>This view has no delivered Postbox messages.</p>
</div>
) : null}
{messages.length ? (
<SelectionList label="Postbox messages">
{messages.map((message) => (
<SelectionListItem
key={message.id}
selected={selectedMessageId === message.id}
className={`postbox-message-item ${message.read_at ? "is-read" : "is-unread"}`}
onClick={() => setSelectedMessageId(message.id)}
>
<span className="postbox-message-heading">
<strong>{message.subject}</strong>
<time>{formatDate(message.delivered_at, language)}</time>
</span>
<span className="postbox-message-preview">
{message.sender_label || message.producer_module || "Platform"}
</span>
<span className="postbox-message-meta">
<span>{sourceName(postboxes, message.postbox_id)}</span>
{message.attachments.length ? (
<span><Paperclip size={13} /> {message.attachments.length}</span>
) : null}
{message.acknowledged_at ? (
<span><CheckCheck size={13} /> Acknowledged</span>
) : null}
{message.availability !== "available" ? (
<StatusBadge
status="warning"
label={message.availability === "withdrawn" ? "Withdrawn" : "Expired"}
/>
) : null}
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</div>
<DataGridPaginationBar
page={page}
pageSize={pageSize}
totalRows={total}
pageSizeOptions={[25, 50, 100, 200]}
disabled={loadingMessages}
ariaLabel="Postbox message pagination"
onPageChange={(next) => {
setPage(next);
setSelectedMessageId("");
setSelectedMessage(null);
}}
onPageSizeChange={(next) => {
setPageSize(next);
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
</section>
<section className="postbox-detail">
<div className="postbox-bar">
<div className="postbox-bar-title">
<MailOpen size={17} aria-hidden="true" />
<strong>{selectedMessage?.subject || "Message"}</strong>
</div>
<div className="button-row compact-actions">
<Button
onClick={openReply}
disabled={Boolean(replyDisabledReason)}
disabledReason={replyDisabledReason}
>
<Reply size={16} /> Reply
</Button>
<Button
onClick={() => void acknowledgeSelected()}
disabled={Boolean(acknowledgeDisabledReason)}
disabledReason={acknowledgeDisabledReason}
>
<CheckCheck size={16} /> Acknowledge
</Button>
</div>
</div>
{selectedMessage ? (
<MessageDetail
message={selectedMessage}
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
/>
) : unavailableSelection ? (
<div className="postbox-empty postbox-unavailable-message">
<Archive size={24} />
<strong>Message unavailable</strong>
<p>{unavailableSelection}</p>
</div>
) : (
<div className="postbox-empty">
<Inbox size={24} />
<strong>Select a message</strong>
<p>Source, function context, content, and evidence remain attached to the originating postbox.</p>
</div>
)}
</section>
</div>
<Dialog
open={groupingDialogOpen}
title={groupingDraft.id ? "Edit unified view" : "New unified view"}
className="postbox-dialog"
onClose={closeGroupingDialog}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions">
{groupingDraft.id ? (
<Button
variant="danger"
onClick={() => setDeleteGroupingTarget(groupingDraft)}
disabled={busy}
disabledReason={postboxBusyReason(false, busy)}
>
<Trash2 size={16} /> Delete
</Button>
) : <span />}
<div className="button-row compact-actions">
<Button onClick={closeGroupingDialog} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void saveGrouping()}
disabled={busy || !groupingDraft.name.trim()}
disabledReason={postboxBusyReason(false, busy) ?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
Save
</Button>
</div>
</div>
}
>
<div className="postbox-form-grid">
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={groupingDraft.name}
onChange={(event) =>
setGroupingDraft((current) => ({
...current,
name: event.target.value
}))
}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Default unified view"
checked={groupingDraft.is_default}
onChange={(checked) =>
setGroupingDraft((current) => ({
...current,
is_default: checked
}))
}
/>
</div>
</div>
<fieldset className="postbox-source-selector">
<legend>Source postboxes</legend>
{postboxes.map((postbox) => (
<label key={postbox.id}>
<input
type="checkbox"
checked={groupingDraft.postbox_ids.includes(postbox.id)}
onChange={(event) =>
setGroupingDraft((current) => ({
...current,
postbox_ids: event.target.checked
? [...current.postbox_ids, postbox.id]
: current.postbox_ids.filter((id) => id !== postbox.id)
}))
}
/>
<span>
<strong>{postbox.name}</strong>
<small>{postbox.organization_unit_name} · {postbox.function_name}</small>
</span>
</label>
))}
</fieldset>
</Dialog>
<Dialog
open={messageDialogOpen}
title={replyParent ? "Reply" : "New message"}
className="postbox-dialog postbox-message-dialog"
onClose={closeMessageDialog}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions end">
<Button onClick={closeMessageDialog} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void submitMessage()}
disabled={
busy ||
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
}
disabledReason={postboxBusyReason(false, busy) ?? ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
<Send size={16} /> Send
</Button>
</div>
}
>
<div className="postbox-compose-grid">
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.postbox_id}
disabled={Boolean(replyParent)}
onChange={(event) => {
const postbox = postboxes.find(
(item) => item.id === event.target.value
);
setMessageDraft((current) => ({
...current,
postbox_id: event.target.value,
classification: postbox?.classification ?? "internal"
}));
}}
>
{postboxes.map((postbox) => (
<option key={postbox.id} value={postbox.id}>
{postbox.name}
</option>
))}
</select>
</FormField>
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.classification}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
classification: event.target.value
}))
}
>
{classificationOptions(
postboxes.find((item) => item.id === messageDraft.postbox_id)
?.classification ?? "internal",
replyParent?.classification
).map((classification) => (
<option key={classification} value={classification}>
{classificationLabel(classification)}
</option>
))}
</select>
</FormField>
<div className="postbox-compose-wide">
<FormField label="Recipients" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={messageDraft.recipients}
placeholder="Separate addresses with commas"
onChange={(event) =>
setMessageDraft((current) => ({
...current,
recipients: event.target.value
}))
}
/>
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Subject" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={messageDraft.subject}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
subject: event.target.value
}))
}
/>
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Message" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<textarea
rows={10}
value={messageDraft.body_text}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
body_text: event.target.value
}))
}
/>
</FormField>
</div>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(deleteGroupingTarget)}
title="Delete unified view"
message={deleteGroupingTarget
? i18nMessage("i18n:govoplan-postbox.delete_grouping_confirmation", { name: deleteGroupingTarget.name })
: ""}
confirmLabel="Delete"
tone="danger"
busy={busy}
onConfirm={() => void removeGrouping()}
onCancel={() => setDeleteGroupingTarget(null)}
/>
</main>
);
}
function MessageDetail({
message,
postbox
}: {
message: PostboxMessage;
postbox?: PostboxDirectoryItem;
}) {
const { language } = usePlatformLanguage();
return (
<div className="postbox-message-detail">
{message.availability !== "available" ? (
<DismissibleAlert
tone="warning"
compact
resetKey={`${message.id}:${message.availability}`}
>
{message.availability === "withdrawn"
? "This message was withdrawn. Future access is blocked and audit metadata remains visible. Plaintext already decrypted, copied, exported, or printed cannot be retracted."
: "This message has expired. Its audit metadata remains visible, but its content and actions are unavailable."}
</DismissibleAlert>
) : null}
<header>
<div className="postbox-detail-status">
<StatusBadge status={message.status} />
<StatusBadge status={message.classification} />
{message.acknowledged_at ? (
<StatusBadge status="success" label="Acknowledged" />
) : null}
</div>
<h1>{message.subject}</h1>
<div className="postbox-detail-byline">
<span>{message.sender_label || message.producer_module || "Platform"}</span>
<time>{formatLongDate(message.delivered_at, language)}</time>
</div>
</header>
<section className="postbox-provenance">
<h2>Source and responsibility</h2>
<dl>
<div><dt>Postbox</dt><dd>{postbox?.name || message.postbox_id}</dd></div>
<div><dt>Organization</dt><dd>{postbox?.organization_unit_name || "Not recorded"}</dd></div>
<div><dt>Function</dt><dd>{postbox?.function_name || "Not recorded"}</dd></div>
<div><dt>Address</dt><dd>{postbox?.address || "Not loaded"}</dd></div>
<div><dt>Producer</dt><dd>{producerLabel(message)}</dd></div>
<div><dt>Encryption profile</dt><dd>{message.encryption_profile} · epoch {message.key_epoch}</dd></div>
{message.wrapped_keys.length ? (
<div><dt>Key envelopes</dt><dd>{message.wrapped_keys.length}</dd></div>
) : null}
{message.external_recipient_tokens.length ? (
<div><dt>External grants</dt><dd>{message.external_recipient_tokens.length}</dd></div>
) : null}
</dl>
</section>
<section className="postbox-body">
<p>{message.body_text || "No plaintext body is available for this message."}</p>
</section>
{message.participants.length ? (
<section className="postbox-participants">
<h2>Participants</h2>
{message.participants.map((participant, index) => (
<div key={`${participant.kind}-${participant.reference_id || index}`}>
<strong>{participant.kind}</strong>
<span>{participant.label || participant.address || participant.reference_id || participant.reference_type}</span>
</div>
))}
</section>
) : null}
<section className="postbox-attachments">
<h2>Evidence and attachments</h2>
{!message.attachments.length ? <p>No attachment references.</p> : null}
{message.attachments.map((attachment) => (
<div key={`${attachment.reference_type}:${attachment.reference_id}`}>
<Paperclip size={15} />
<span>
<strong>{attachment.name || attachment.reference_id}</strong>
<small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small>
</span>
</div>
))}
</section>
{postbox?.access ? (
<section className="postbox-access-explanation">
<UserRoundCheck size={17} />
<div>
<strong>Current access</strong>
<p>{postbox.access.explanation}</p>
</div>
</section>
) : null}
</div>
);
}
function sourceName(
postboxes: PostboxDirectoryItem[],
postboxId: string
): string {
return postboxes.find((postbox) => postbox.id === postboxId)?.name ?? "Postbox";
}
function producerLabel(message: PostboxMessage): string {
const resource = [
message.producer_module,
message.producer_resource_type,
message.producer_resource_id
].filter(Boolean);
return resource.length ? resource.join(" / ") : "Platform-native message";
}
const CLASSIFICATIONS = [
"public",
"internal",
"confidential",
"restricted"
] as const;
function classificationOptions(
postboxClassification: string,
minimumClassification?: string
): string[] {
const maximum = Math.max(
0,
CLASSIFICATIONS.indexOf(
postboxClassification as (typeof CLASSIFICATIONS)[number]
)
);
const minimum = minimumClassification
? Math.max(
0,
CLASSIFICATIONS.indexOf(
minimumClassification as (typeof CLASSIFICATIONS)[number]
)
)
: 0;
return CLASSIFICATIONS.slice(minimum, maximum + 1);
}
function classificationLabel(value: string): string {
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
function formatDate(value: string, language: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(language, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function formatLongDate(value: string, language: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(language, {
dateStyle: "long",
timeStyle: "short"
}).format(date);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}
function draftKey(value: unknown): string {
return JSON.stringify(value);
}