1267 lines
43 KiB
TypeScript
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);
|
|
}
|