Add Postbox message authoring and concurrency

This commit is contained in:
2026-07-31 18:40:39 +02:00
parent 0b6ebc3c74
commit dc6f81dd33
12 changed files with 1392 additions and 68 deletions
+302 -28
View File
@@ -10,7 +10,9 @@ import {
Pencil,
Plus,
RefreshCw,
Reply,
Search,
Send,
Trash2,
UserRoundCheck,
X
@@ -34,12 +36,14 @@ import {
} from "@govoplan/core-webui";
import {
createPostboxGrouping,
createPostboxMessage,
deletePostboxGrouping,
getPostboxMessage,
listPostboxGroupings,
listPostboxMessages,
listPostboxes,
markPostboxMessage,
replyToPostboxMessage,
updatePostboxGrouping,
type PostboxDirectoryItem,
type PostboxGrouping,
@@ -56,6 +60,14 @@ type GroupingDraft = {
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: "",
@@ -63,6 +75,14 @@ const emptyGrouping = (): GroupingDraft => ({
postbox_ids: []
});
const emptyMessageDraft = (): MessageDraft => ({
postbox_id: "",
subject: "",
body_text: "",
classification: "internal",
recipients: ""
});
export default function PostboxPage({
settings,
auth
@@ -94,8 +114,13 @@ export default function PostboxPage({
const [error, setError] = useState("");
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
const [replyParent, setReplyParent] = useState<PostboxMessage | null>(null);
const [messageDraft, setMessageDraft] = useState<MessageDraft>(emptyMessageDraft);
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
const canSend = hasScope(auth, "postbox:message:write");
const canReply = hasScope(auth, "postbox:message:reply");
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
[postboxes, selectedPostboxId]
@@ -335,8 +360,9 @@ export default function PostboxPage({
postbox_ids: groupingDraft.postbox_ids
};
try {
const saved = groupingDraft.id
? await updatePostboxGrouping(settings, groupingDraft.id, payload)
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);
@@ -354,7 +380,9 @@ export default function PostboxPage({
setBusy(true);
setError("");
try {
await deletePostboxGrouping(settings, groupingDraft.id);
const existing = groupings.find((item) => item.id === groupingDraft.id);
if (!existing) throw new Error("The grouping is no longer available.");
await deletePostboxGrouping(settings, existing);
setSelectedScope("all");
setSelectedPostboxId("");
setGroupingDialogOpen(false);
@@ -366,6 +394,86 @@ export default function PostboxPage({
}
}
function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null;
if (!postbox) return;
setReplyParent(null);
setMessageDraft({
...emptyMessageDraft(),
postbox_id: postbox.id,
classification: postbox.classification
});
setMessageDialogOpen(true);
}
function openReply() {
if (!selectedMessage) return;
setReplyParent(selectedMessage);
setMessageDraft({
...emptyMessageDraft(),
postbox_id: selectedMessage.postbox_id,
subject: selectedMessage.subject.toLowerCase().startsWith("re:")
? selectedMessage.subject
: `Re: ${selectedMessage.subject}`,
classification: selectedMessage.classification
});
setMessageDialogOpen(true);
}
async function submitMessage() {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return;
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);
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
return (
<main className="workspace-data-page module-entry-page postbox-page">
<div className="postbox-shell">
@@ -472,12 +580,20 @@ export default function PostboxPage({
)}
<span className="postbox-total">{total}</span>
</div>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
disabled={loadingMessages || busy}
/>
<div className="postbox-icon-actions">
<IconButton
label="New message"
icon={<Send size={16} />}
onClick={openCompose}
disabled={!canSend || !postboxes.length || busy}
/>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
disabled={loadingMessages || busy}
/>
</div>
</div>
<div className="postbox-message-filters">
<div className="postbox-search-row">
@@ -615,25 +731,38 @@ export default function PostboxPage({
<MailOpen size={17} aria-hidden="true" />
<strong>{selectedMessage?.subject || "Message"}</strong>
</div>
<Button
onClick={() => void acknowledgeSelected()}
disabled={
!selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
>
<CheckCheck size={16} /> Acknowledge
</Button>
<div className="button-row compact-actions">
<Button
onClick={openReply}
disabled={
!canReply ||
!selectedMessage ||
selectedMessage.availability !== "available" ||
busy
}
>
<Reply size={16} /> Reply
</Button>
<Button
onClick={() => void acknowledgeSelected()}
disabled={
!selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
>
<CheckCheck size={16} /> Acknowledge
</Button>
</div>
</div>
{selectedMessage ? (
<MessageDetail
@@ -737,6 +866,119 @@ export default function PostboxPage({
))}
</fieldset>
</Dialog>
<Dialog
open={messageDialogOpen}
title={replyParent ? "Reply" : "New message"}
className="postbox-dialog postbox-message-dialog"
onClose={() => setMessageDialogOpen(false)}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions end">
<Button onClick={() => setMessageDialogOpen(false)} disabled={busy}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void submitMessage()}
disabled={
busy ||
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
}
>
<Send size={16} /> Send
</Button>
</div>
}
>
<div className="postbox-compose-grid">
<FormField label="Postbox">
<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">
<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">
<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">
<input
value={messageDraft.subject}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
subject: event.target.value
}))
}
/>
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Message">
<textarea
rows={10}
value={messageDraft.body_text}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
body_text: event.target.value
}))
}
/>
</FormField>
</div>
</div>
</Dialog>
</main>
);
}
@@ -842,6 +1084,38 @@ function producerLabel(message: PostboxMessage): string {
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): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;