Complete files UI rewiring to the deployed api surface; expose mimeClass

page.tsx now calls the real backend: GET /files with groupId/name/mimeClass/
cursor/limit (origin filter dropped — the api has no origin param), shares
tracked as ShareCreated (revoke by id), retention mapped to the orthogonal
legalHold model, downloads by item.name, samples reshaped to the wire
contract.

GroupFile gains mimeClass (the indexed coarse class the row icon and the
filter depend on): core contract, service row mapper, parseGroupFile guard,
test fixture, UI types — one vocabulary end to end. parseGroupFile's roomId
becomes null-tolerant to match direct-upload rows (mapping input stays
strict).

Verified: core build clean, api typecheck 0 errors + group-files 22/22,
web typecheck 0 errors.
This commit is contained in:
glass beadian 2026-10-11 11:08:08 +00:00
parent e7e703c105
commit 4bf90ffe25
6 changed files with 53 additions and 49 deletions

View file

@ -160,6 +160,7 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str
origin, origin,
name: row.original_name, name: row.original_name,
mimeType: row.mime, mimeType: row.mime,
mimeClass: row.mime_class as MimeClass,
sizeBytes: safeNumber(row.byte_size), sizeBytes: safeNumber(row.byte_size),
checksum: { algorithm: "sha256", value: row.sha256 }, checksum: { algorithm: "sha256", value: row.sha256 },
createdAt: iso(row.created_at), createdAt: iso(row.created_at),

View file

@ -135,6 +135,7 @@ describe("group-files runtime validation (shared contracts)", () => {
origin: { kind: "matrix", mxcUri: "mxc://example.org/x" }, origin: { kind: "matrix", mxcUri: "mxc://example.org/x" },
name: "clip.mp4", name: "clip.mp4",
mimeType: "video/mp4", mimeType: "video/mp4",
mimeClass: "video",
sizeBytes: 12, sizeBytes: 12,
checksum: { algorithm: "sha256", value: hex64("z") }, checksum: { algorithm: "sha256", value: hex64("z") },
createdAt: "2026-01-02T03:04:05Z", createdAt: "2026-01-02T03:04:05Z",

View file

@ -216,12 +216,13 @@ export function parseGroupFile(value: unknown, field = "item"): GroupFile {
id: stringField(value, "id", `${field}.id`), id: stringField(value, "id", `${field}.id`),
workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`), workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`),
groupId: stringField(value, "groupId", `${field}.groupId`), groupId: stringField(value, "groupId", `${field}.groupId`),
roomId: requireRoomId(value.roomId, `${field}.roomId`), roomId: value.roomId === undefined || value.roomId === null ? null : requireRoomId(value.roomId, `${field}.roomId`),
senderId: requireMxid(value.senderId, `${field}.senderId`), senderId: requireMxid(value.senderId, `${field}.senderId`),
eventId: eventId === undefined || eventId === null ? null : eventId, eventId: eventId === undefined || eventId === null ? null : eventId,
origin, origin,
name: stringField(value, "name", `${field}.name`), name: stringField(value, "name", `${field}.name`),
mimeType: stringField(value, "mimeType", `${field}.mimeType`), mimeType: stringField(value, "mimeType", `${field}.mimeType`),
mimeClass: enumField(value, "mimeClass", MIME_CLASSES, `${field}.mimeClass`),
sizeBytes: integerField(value, "sizeBytes", `${field}.sizeBytes`), sizeBytes: integerField(value, "sizeBytes", `${field}.sizeBytes`),
checksum: { algorithm: "sha256", value: requireSha256(checksum.value, `${field}.checksum.value`) }, checksum: { algorithm: "sha256", value: requireSha256(checksum.value, `${field}.checksum.value`) },
createdAt: isoField(value, "createdAt", `${field}.createdAt`), createdAt: isoField(value, "createdAt", `${field}.createdAt`),

View file

@ -14,44 +14,50 @@ import { ShareDialog } from "../../../components/files/share-dialog";
import { RetentionDialog } from "../../../components/files/retention-dialog"; import { RetentionDialog } from "../../../components/files/retention-dialog";
import { downloadGroupFile } from "../../../components/files/files-client"; import { downloadGroupFile } from "../../../components/files/files-client";
import { useFilesLang } from "../../../components/files/i18n"; import { useFilesLang } from "../../../components/files/i18n";
import { MIME_CLASS_LABEL, ORIGIN_LABEL } from "../../../components/files/labels"; import { MIME_CLASS_LABEL } from "../../../components/files/labels";
import type { import type {
FileOrigin, GroupFile,
GroupFileItem,
GroupFilePage, GroupFilePage,
GroupSummary, GroupSummary,
MimeClass, MimeClass,
RetentionInput, RetentionInput,
ShareCreated,
} from "../../../components/files/types"; } from "../../../components/files/types";
/** /**
* Group file library — the per-group "group cloud". Live mode pages through * Group file library — the per-group "group cloud". Live mode pages through
* GET /files/groups/:groupId/items by cursor (video library's load-more * GET /files by cursor (video library's load-more pattern) with a debounced
* pattern) with a debounced server-side `q` and mimeClass/origin filters. * server-side `name` search and a mimeClass filter. Offline the screen
* Offline the screen renders sample data read-only per useApiResource rules. * renders sample data read-only per useApiResource rules.
*/ */
const PAGE_LIMIT = 50; const PAGE_LIMIT = 50;
const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }]; const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }];
const SAMPLE_FILES: GroupFileItem[] = [ const SAMPLE_FILES: GroupFile[] = [
{ {
id: "sample-1", groupId: "sample-group", origin: "matrix", senderMxid: "@ada:collective.example", id: "sample-1", workspaceId: "sample-ws", groupId: "sample-group", roomId: null, senderId: "@ada:collective.example",
originalName: "press-kit.zip", safeName: "press-kit.zip", mime: "application/zip", mimeClass: "other", eventId: null, origin: { kind: "matrix" }, name: "press-kit.zip", mimeType: "application/zip", mimeClass: "other",
byteSize: 4_812_441, sha256: "a".repeat(64), createdAt: "2026-10-08T09:12:00.000Z", available: true, sizeBytes: 4_812_441, checksum: { value: "a".repeat(64) }, createdAt: "2026-10-08T09:12:00.000Z",
retentionMode: "keep", retentionUntil: null, indexedAt: "2026-10-08T09:12:05.000Z", file: { resourceId: "res-sample-1" },
shareLink: { url: "", access: "members", expiresAt: null },
retention: { mode: "keep", legalHold: false, expiresAt: null }, status: "available",
}, },
{ {
id: "sample-2", groupId: "sample-group", origin: "upload", senderMxid: "@you:collective.example", id: "sample-2", workspaceId: "sample-ws", groupId: "sample-group", roomId: null, senderId: "@you:collective.example",
originalName: "launch-banner.png", safeName: "launch-banner.png", mime: "image/png", mimeClass: "image", eventId: null, origin: { kind: "upload" }, name: "launch-banner.png", mimeType: "image/png", mimeClass: "image",
byteSize: 348_112, sha256: "b".repeat(64), createdAt: "2026-10-08T11:40:00.000Z", available: true, sizeBytes: 348_112, checksum: { value: "b".repeat(64) }, createdAt: "2026-10-08T11:40:00.000Z",
retentionMode: "until", retentionUntil: "2026-11-30T00:00:00.000Z", indexedAt: "2026-10-08T11:40:05.000Z", file: { resourceId: "res-sample-2" },
shareLink: { url: "", access: "members", expiresAt: null },
retention: { mode: "until", legalHold: false, expiresAt: "2026-11-30T00:00:00.000Z" }, status: "available",
}, },
{ {
id: "sample-3", groupId: "sample-group", origin: "bridge", senderMxid: "@bridge:collective.example", id: "sample-3", workspaceId: "sample-ws", groupId: "sample-group", roomId: null, senderId: "@bridge:collective.example",
originalName: "statement-final.pdf", safeName: "statement-final.pdf", mime: "application/pdf", mimeClass: "document", eventId: "evt-1", origin: { kind: "bridge" }, name: "statement-final.pdf", mimeType: "application/pdf", mimeClass: "document",
byteSize: 1_204_998, sha256: "c".repeat(64), createdAt: "2026-10-07T18:05:00.000Z", available: true, sizeBytes: 1_204_998, checksum: { value: "c".repeat(64) }, createdAt: "2026-10-07T18:05:00.000Z",
retentionMode: "legalHold", retentionUntil: null, indexedAt: "2026-10-07T18:05:05.000Z", file: { resourceId: "res-sample-3" },
shareLink: { url: "", access: "members", expiresAt: null },
retention: { mode: "keep", legalHold: true, expiresAt: null }, status: "available",
}, },
]; ];
@ -63,7 +69,7 @@ export default function FilesPage(): React.ReactElement {
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS); const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
const [pickedGroup, setPickedGroup] = useState<string | null>(null); const [pickedGroup, setPickedGroup] = useState<string | null>(null);
const [items, setItems] = useState<GroupFileItem[]>([]); const [items, setItems] = useState<GroupFile[]>([]);
const [source, setSource] = useState<"loading" | "live" | "sample">("loading"); const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null); const [nextCursor, setNextCursor] = useState<string | null>(null);
@ -74,14 +80,13 @@ export default function FilesPage(): React.ReactElement {
const [qInput, setQInput] = useState(""); const [qInput, setQInput] = useState("");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [mimeClass, setMimeClass] = useState<"" | MimeClass>(""); const [mimeClass, setMimeClass] = useState<"" | MimeClass>("");
const [origin, setOrigin] = useState<"" | FileOrigin>("");
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [shareFor, setShareFor] = useState<GroupFileItem | null>(null); const [shareFor, setShareFor] = useState<GroupFile | null>(null);
const [retentionFor, setRetentionFor] = useState<GroupFileItem | null>(null); const [retentionFor, setRetentionFor] = useState<GroupFile | null>(null);
const [shares, setShares] = useState<Record<string, { shareId: string | null; tokenUrl: string }>>({}); const [shares, setShares] = useState<Record<string, ShareCreated>>({});
const groupId = pickedGroup ?? groups.data[0]?.id ?? ""; const groupId = pickedGroup ?? groups.data[0]?.id ?? "";
const live = source === "live"; const live = source === "live";
@ -111,7 +116,7 @@ export default function FilesPage(): React.ReactElement {
const controller = new AbortController(); const controller = new AbortController();
setLoading(true); setLoading(true);
api api
.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, { q, mimeClass, origin, limit: PAGE_LIMIT }, controller.signal) .get<GroupFilePage>("/files", { groupId, name: q, mimeClass, limit: PAGE_LIMIT }, controller.signal)
.then((page) => { .then((page) => {
setItems(page.items); setItems(page.items);
setNextCursor(page.nextCursor); setNextCursor(page.nextCursor);
@ -133,14 +138,14 @@ export default function FilesPage(): React.ReactElement {
if (!controller.signal.aborted) setLoading(false); if (!controller.signal.aborted) setLoading(false);
}); });
return () => controller.abort(); return () => controller.abort();
}, [authStatus, groupId, q, mimeClass, origin, nonce]); }, [authStatus, groupId, q, mimeClass, nonce]);
async function loadMore(): Promise<void> { async function loadMore(): Promise<void> {
if (nextCursor === null || groupId === "") return; if (nextCursor === null || groupId === "") return;
setLoadingMore(true); setLoadingMore(true);
try { try {
const page = await api.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, { const page = await api.get<GroupFilePage>("/files", {
q, mimeClass, origin, limit: PAGE_LIMIT, cursor: nextCursor, groupId, name: q, mimeClass, limit: PAGE_LIMIT, cursor: nextCursor,
}); });
setItems((current) => { setItems((current) => {
const seen = new Set(current.map((item) => item.id)); const seen = new Set(current.map((item) => item.id));
@ -154,24 +159,24 @@ export default function FilesPage(): React.ReactElement {
} }
} }
const handleUploaded = useCallback((item: GroupFileItem): void => { const handleUploaded = useCallback((item: GroupFile): void => {
setItems((current) => [item, ...current.filter((existing) => existing.id !== item.id)]); setItems((current) => [item, ...current.filter((existing) => existing.id !== item.id)]);
setNotice(t({ en: "File uploaded.", es: "Archivo subido." })); setNotice(t({ en: "File uploaded.", es: "Archivo subido." }));
}, [t]); }, [t]);
async function handleDownload(item: GroupFileItem): Promise<void> { async function handleDownload(item: GroupFile): Promise<void> {
setActionError(null); setActionError(null);
try { try {
await downloadGroupFile(item.id, item.safeName); await downloadGroupFile(item.id, item.name);
} catch (err) { } catch (err) {
setActionError(errorMessage(err)); setActionError(errorMessage(err));
} }
} }
async function revokeShare(item: GroupFileItem): Promise<void> { async function revokeShare(item: GroupFile): Promise<void> {
const entry = shares[item.id]; const entry = shares[item.id];
if (entry === undefined || entry.shareId === null || entry.shareId === "") { if (entry === undefined) {
setActionError(t({ en: "No share id to revoke — reopen the share dialog.", es: "No hay id de enlace que revocar — vuelve a abrir el cuadro de compartir." })); setActionError(t({ en: "No share to revoke — open the share dialog first.", es: "No hay enlace que revocar — abre primero el cuadro de compartir." }));
return; return;
} }
const previous = shares; const previous = shares;
@ -184,7 +189,7 @@ export default function FilesPage(): React.ReactElement {
return next; return next;
}); });
try { try {
await api.del(`/files/shares/${encodeURIComponent(entry.shareId)}`); await api.del(`/files/shares/${encodeURIComponent(entry.id)}`);
setNotice(t({ en: "Share link revoked.", es: "Enlace para compartir revocado." })); setNotice(t({ en: "Share link revoked.", es: "Enlace para compartir revocado." }));
} catch (err) { } catch (err) {
setShares(previous); setShares(previous);
@ -197,7 +202,9 @@ export default function FilesPage(): React.ReactElement {
function handleRetentionSaved(itemId: string, input: RetentionInput): void { function handleRetentionSaved(itemId: string, input: RetentionInput): void {
setItems((current) => setItems((current) =>
current.map((item) => current.map((item) =>
item.id === itemId ? { ...item, retentionMode: input.mode, retentionUntil: input.until ?? null } : item, item.id === itemId
? { ...item, retention: { mode: input.mode, legalHold: input.legalHold ?? false, expiresAt: input.until ?? null } }
: item,
), ),
); );
setNotice(t({ en: "Retention updated.", es: "Retención actualizada." })); setNotice(t({ en: "Retention updated.", es: "Retención actualizada." }));
@ -277,17 +284,6 @@ export default function FilesPage(): React.ReactElement {
<option key={value} value={value}>{t(MIME_CLASS_LABEL[value])}</option> <option key={value} value={value}>{t(MIME_CLASS_LABEL[value])}</option>
))} ))}
</select> </select>
<select
aria-label={t({ en: "Origin", es: "Origen" })}
value={origin}
onChange={(event) => setOrigin(event.target.value as "" | FileOrigin)}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs"
>
<option value="">{t({ en: "All origins", es: "Todos los orígenes" })}</option>
{(Object.keys(ORIGIN_LABEL) as FileOrigin[]).map((value) => (
<option key={value} value={value}>{t(ORIGIN_LABEL[value])}</option>
))}
</select>
{live && ( {live && (
<Button size="sm" variant="ghost" onClick={() => setNonce((n) => n + 1)} disabled={loading}> <Button size="sm" variant="ghost" onClick={() => setNonce((n) => n + 1)} disabled={loading}>
{t({ en: "Refresh", es: "Actualizar" })} {t({ en: "Refresh", es: "Actualizar" })}
@ -327,7 +323,7 @@ export default function FilesPage(): React.ReactElement {
<EmptyState <EmptyState
title={t({ en: "No files here yet", es: "Aún no hay archivos aquí" })} title={t({ en: "No files here yet", es: "Aún no hay archivos aquí" })}
hint={ hint={
q !== "" || mimeClass !== "" || origin !== "" q !== "" || mimeClass !== ""
? t({ en: "No file matches these filters. Clear the search or filters.", es: "Ningún archivo coincide con estos filtros. Limpia la búsqueda o los filtros." }) ? t({ en: "No file matches these filters. Clear the search or filters.", es: "Ningún archivo coincide con estos filtros. Limpia la búsqueda o los filtros." })
: t({ en: "Drop a file above, or wait for a room attachment to arrive.", es: "Suelta un archivo arriba, o espera a que llegue un adjunto de la sala." }) : t({ en: "Drop a file above, or wait for a room attachment to arrive.", es: "Suelta un archivo arriba, o espera a que llegue un adjunto de la sala." })
} }

View file

@ -29,6 +29,7 @@ export interface GroupFile {
origin: { kind: FileOrigin }; origin: { kind: FileOrigin };
name: string; name: string;
mimeType: string; mimeType: string;
mimeClass: MimeClass;
sizeBytes: number; sizeBytes: number;
checksum: { value: string }; checksum: { value: string };
createdAt: string; createdAt: string;

View file

@ -22,6 +22,8 @@ export type SharedFileOrigin =
| { kind: "upload" } | { kind: "upload" }
| { kind: "pad-export"; padId: string; revision: string }; | { kind: "pad-export"; padId: string; revision: string };
import type { MimeClass } from "./file-mime.js";
export interface GroupFile { export interface GroupFile {
id: string; id: string;
workspaceId: string; workspaceId: string;
@ -34,6 +36,8 @@ export interface GroupFile {
origin: SharedFileOrigin; origin: SharedFileOrigin;
name: string; name: string;
mimeType: string; mimeType: string;
/** Coarse class derived from the payload (image/video/audio/document/archive/other). */
mimeClass: MimeClass;
sizeBytes: number; sizeBytes: number;
/** SHA-256 of verified payload bytes, lowercase hex; never an S3 ETag. */ /** SHA-256 of verified payload bytes, lowercase hex; never an S3 ETag. */
checksum: { algorithm: "sha256"; value: string }; checksum: { algorithm: "sha256"; value: string };