feat: integrate files and portal surfaces

This commit is contained in:
2026-08-07 14:53:46 +02:00
parent 60f50f7906
commit 11f175cbb3
13 changed files with 512 additions and 13 deletions
+57 -5
View File
@@ -3,6 +3,7 @@ import {
Archive,
Building2,
CheckCheck,
Download,
Inbox,
Layers3,
MailOpen,
@@ -45,15 +46,18 @@ import {
createPostboxGrouping,
createPostboxMessage,
deletePostboxGrouping,
downloadPostboxAttachment,
getPostboxMessage,
listPostboxGroupings,
listPostboxMessages,
listPostboxes,
markPostboxMessage,
resolvePostboxAttachments,
replyToPostboxMessage,
updatePostboxGrouping,
type PostboxDirectoryItem,
type PostboxGrouping,
type PostboxAttachmentResolution,
type PostboxMessage
} from "../../api/postbox";
import {
@@ -106,6 +110,9 @@ export default function PostboxPage({
const requestedMessageId = useRef(
new URLSearchParams(window.location.search).get("message") ?? ""
);
const requestedPostboxId = useRef(
new URLSearchParams(window.location.search).get("postbox") ?? ""
);
const requestedMessageLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
@@ -114,6 +121,7 @@ export default function PostboxPage({
const [messages, setMessages] = useState<PostboxMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState("");
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
const [attachmentResolutions, setAttachmentResolutions] = useState<PostboxAttachmentResolution[]>([]);
const [unavailableSelection, setUnavailableSelection] = useState("");
const [total, setTotal] = useState(0);
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
@@ -204,7 +212,9 @@ export default function PostboxPage({
setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current)
? current
: ""
: nextPostboxes.some((postbox) => postbox.id === requestedPostboxId.current)
? requestedPostboxId.current
: ""
);
} catch (loadError) {
setError(errorMessage(loadError));
@@ -316,14 +326,19 @@ export default function PostboxPage({
if (message.availability === "available" && !message.read_at) {
message = await markPostboxMessage(settings, selectedMessageId, "read");
}
const resolutions = message.attachments.length
? await resolvePostboxAttachments(settings, message.id)
: [];
if (cancelled) return;
setSelectedMessage(message);
setAttachmentResolutions(resolutions);
setMessages((items) =>
items.map((item) => (item.id === message.id ? message : item))
);
} catch (loadError) {
if (!cancelled && isApiError(loadError, 403, 404)) {
setSelectedMessage(null);
setAttachmentResolutions([]);
setUnavailableSelection(
"This message is no longer available or is outside your current Postbox assignments."
);
@@ -866,6 +881,12 @@ export default function PostboxPage({
<MessageDetail
message={selectedMessage}
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
attachmentResolutions={attachmentResolutions}
onDownload={(attachment) => {
void downloadPostboxAttachment(settings, attachment).catch((downloadError) => {
setError(errorMessage(downloadError));
});
}}
/>
) : unavailableSelection ? (
<div className="postbox-empty postbox-unavailable-message">
@@ -1099,10 +1120,14 @@ export default function PostboxPage({
function MessageDetail({
message,
postbox
postbox,
attachmentResolutions,
onDownload
}: {
message: PostboxMessage;
postbox?: PostboxDirectoryItem;
attachmentResolutions: PostboxAttachmentResolution[];
onDownload: (attachment: PostboxAttachmentResolution) => void;
}) {
const { language } = usePlatformLanguage();
return (
@@ -1166,15 +1191,30 @@ function MessageDetail({
<section className="postbox-attachments">
<h2>Evidence and attachments</h2>
{!message.attachments.length ? <p>No attachment references.</p> : null}
{message.attachments.map((attachment) => (
{message.attachments.map((attachment) => {
const resolution = attachmentResolutions.find(
(item) => item.reference_type === attachment.reference_type
&& item.reference_id === attachment.reference_id
);
return (
<div key={`${attachment.reference_type}:${attachment.reference_id}`}>
<Paperclip size={15} />
<span>
<strong>{attachment.name || attachment.reference_id}</strong>
<strong>{resolution?.name || attachment.name || attachment.reference_id}</strong>
<small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small>
{resolution && !resolution.available ? (
<small>{attachmentResolutionExplanation(resolution.reason_code)}</small>
) : null}
</span>
{resolution?.available ? (
<IconButton
icon={<Download size={15} />}
label={`Download ${resolution.name || attachment.name || "attachment"}`}
onClick={() => onDownload(resolution)}
/>
) : null}
</div>
))}
)})}
</section>
{postbox?.access ? (
<section className="postbox-access-explanation">
@@ -1189,6 +1229,18 @@ function MessageDetail({
);
}
function attachmentResolutionExplanation(reasonCode: string): string {
const explanations: Record<string, string> = {
download_permission_missing: "Files download permission is required.",
file_access_denied: "The referenced file is outside your current Files access.",
file_not_found: "The referenced file or version no longer exists.",
file_payload_missing: "The referenced file payload is unavailable.",
files_provider_unavailable: "Files is not available in this installation.",
reference_provider_unavailable: "No provider can open this evidence type."
};
return explanations[reasonCode] || "The referenced payload cannot currently be opened.";
}
function sourceName(
postboxes: PostboxDirectoryItem[],
postboxId: string