Integrate group files: nullable room for uploads, archive class, claim idempotency, upload route
Wires the three parallel group-cloud tracks to one surface and fixes the seams between them: - chat_media_archive.room_id becomes nullable (direct web uploads have no Matrix room yet — the contract's null-event pattern extends to the room); core GroupFile.roomId and ArchiveEntryInput.roomId follow. - mime_class gains 'archive' (zip) across the 029 CHECK, validation MIME_CLASSES and the bot classifier — one vocabulary everywhere. - registerArchiveEntry claim keys are null-safe for room-less uploads. - New: POST /files/groups/:groupId/upload — FileInterceptor (20 MiB) streams through magic-byte sniffing, library-safe naming, oCIS upload and the metadata index, behind group-membership ACL (Owner/Admin bypass). - safeFileName/sniffMime promoted to packages/core (file-names.ts, file-mime.ts) so the bot and the api share one implementation; the bot's local modules become re-export shims; @types/node added to core. Verified: api typecheck clean, group-files 22/22, bot 74/74, web typecheck clean; 029 re-applied clean on a fresh scratch DB (room_id nullable, archive class, claim_key unique all confirmed in the catalog).
This commit is contained in:
parent
688a2ce996
commit
e7e703c105
21 changed files with 473 additions and 227 deletions
|
|
@ -3,13 +3,13 @@
|
|||
import { Badge, Button, Card } from "../ui/primitives";
|
||||
import { formatBytes } from "../video/format";
|
||||
import { MimeIcon } from "./mime-icon";
|
||||
import { mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "./labels";
|
||||
import { LEGAL_HOLD_LABEL, mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "./labels";
|
||||
import { useFilesLang } from "./i18n";
|
||||
import type { GroupFileItem } from "./types";
|
||||
import type { GroupFile } from "./types";
|
||||
|
||||
/** One file row: identity + metadata on the left, actions on the right. */
|
||||
export function FileRow({ item, live, canRetention, busy, hasActiveShare, onDownload, onShare, onRevoke, onRetention }: {
|
||||
item: GroupFileItem;
|
||||
item: GroupFile;
|
||||
live: boolean;
|
||||
canRetention: boolean;
|
||||
busy: boolean;
|
||||
|
|
@ -20,28 +20,35 @@ export function FileRow({ item, live, canRetention, busy, hasActiveShare, onDown
|
|||
onRetention: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useFilesLang();
|
||||
const disabled = busy || !live || !item.available;
|
||||
const disabled = busy || !live || item.status !== "available";
|
||||
const retentionLabel =
|
||||
item.retentionMode === "until" && item.retentionUntil !== null
|
||||
? `${t(RETENTION_LABEL.until)} ${new Date(item.retentionUntil).toLocaleDateString()}`
|
||||
: t(RETENTION_LABEL[item.retentionMode]);
|
||||
item.retention.mode === "until" && item.retention.expiresAt !== null
|
||||
? `${t(RETENTION_LABEL.until)} ${new Date(item.retention.expiresAt).toLocaleDateString()}`
|
||||
: t(RETENTION_LABEL[item.retention.mode]);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-wrap items-center gap-3 p-3">
|
||||
<MimeIcon mimeClass={item.mimeClass} size={30} className="shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium" title={item.originalName}>{item.safeName}</span>
|
||||
<Badge tone={ORIGIN_TONE[item.origin]}>{t(ORIGIN_LABEL[item.origin])}</Badge>
|
||||
<Badge tone={RETENTION_TONE[item.retentionMode]}>{retentionLabel}</Badge>
|
||||
{!item.available && <Badge tone="danger">{t({ en: "Unavailable", es: "No disponible" })}</Badge>}
|
||||
<span className="truncate text-sm font-medium" title={item.name}>{item.name}</span>
|
||||
<Badge tone={ORIGIN_TONE[item.origin.kind]}>{t(ORIGIN_LABEL[item.origin.kind])}</Badge>
|
||||
<Badge tone={RETENTION_TONE[item.retention.mode]}>{retentionLabel}</Badge>
|
||||
{item.retention.legalHold && <Badge tone="danger">{t(LEGAL_HOLD_LABEL)}</Badge>}
|
||||
{item.status !== "available" && (
|
||||
<Badge tone="danger">
|
||||
{item.status === "revoked"
|
||||
? t({ en: "Revoked", es: "Revocado" })
|
||||
: t({ en: "Expired", es: "Caducado" })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-text-faint">
|
||||
<span>{formatBytes(item.byteSize)}</span>
|
||||
<span>{formatBytes(item.sizeBytes)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="truncate" title={`${t(mimeClassLabel(item.mimeClass))} · ${item.mime}`}>{t(mimeClassLabel(item.mimeClass))}</span>
|
||||
<span className="truncate" title={`${t(mimeClassLabel(item.mimeClass))} · ${item.mimeType}`}>{t(mimeClassLabel(item.mimeClass))}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="max-w-[16rem] truncate" title={item.senderMxid}>{item.senderMxid}</span>
|
||||
<span className="max-w-[16rem] truncate" title={item.senderId}>{item.senderId}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<time dateTime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { API_URL, ApiError } from "../../lib/api-client";
|
||||
import { getSessionToken } from "../../lib/session-token";
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getSessionToken();
|
||||
return token === null ? {} : { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary download for GET /files/items/:itemId/download. This cannot use
|
||||
* `apiFetch` (it parses JSON) or a bare anchor (the endpoint needs the
|
||||
* Authorization header, which anchors cannot send), so it replays the client's
|
||||
* auth rules: same base URL, Bearer token, cookie credentials — then streams
|
||||
* the body into a Blob and clicks a temporary anchor.
|
||||
* Binary download for GET /files/:id/download. This cannot use `apiFetch`
|
||||
* (it parses JSON) or a bare anchor (the endpoint needs the Authorization
|
||||
* header, which anchors cannot send), so it replays the client's auth rules:
|
||||
* same base URL, Bearer token, cookie credentials — then streams the body
|
||||
* into a Blob and clicks a temporary anchor.
|
||||
*/
|
||||
export async function downloadGroupFile(itemId: string, fileName: string): Promise<void> {
|
||||
const token = getSessionToken();
|
||||
const response = await fetch(`${API_URL}/files/items/${encodeURIComponent(itemId)}/download`, {
|
||||
const response = await fetch(`${API_URL}/files/${encodeURIComponent(itemId)}/download`, {
|
||||
credentials: "include",
|
||||
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new ApiError(401, "Your session expired — sign in again.", null);
|
||||
|
|
@ -29,12 +33,7 @@ export async function downloadGroupFile(itemId: string, fileName: string): Promi
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the share id from a public tokenUrl of the form
|
||||
* `/files/shares/:token/download`, used for DELETE /files/shares/:shareId when
|
||||
* the POST response omits an explicit shareId.
|
||||
*/
|
||||
export function shareIdFromUrl(tokenUrl: string): string | null {
|
||||
const match = /\/files\/shares\/([^/?#]+)/.exec(tokenUrl);
|
||||
return match?.[1] !== undefined && match[1] !== "" ? match[1] : null;
|
||||
/** Public share-claim URL for a minted capability token (GET /public/files/s/:token). */
|
||||
export function publicClaimUrl(token: string): string {
|
||||
return `${API_URL}/public/files/s/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { LocalizedText } from "./i18n";
|
||||
import type { FileOrigin, MimeClass, RetentionMode } from "./types";
|
||||
import type { FileOrigin, FileRetention, MimeClass } from "./types";
|
||||
|
||||
/** Bilingual display labels for the group-files enums (values are fixed by the API). */
|
||||
|
||||
|
|
@ -22,21 +22,22 @@ export const MIME_CLASS_LABEL: Record<MimeClass, LocalizedText> = {
|
|||
video: { en: "Video", es: "Vídeo" },
|
||||
audio: { en: "Audio", es: "Audio" },
|
||||
document: { en: "Document", es: "Documento" },
|
||||
archive: { en: "Archive", es: "Archivo comprimido" },
|
||||
other: { en: "File", es: "Archivo" },
|
||||
};
|
||||
|
||||
export const RETENTION_LABEL: Record<RetentionMode, LocalizedText> = {
|
||||
export const RETENTION_LABEL: Record<FileRetention["mode"], LocalizedText> = {
|
||||
keep: { en: "Keep", es: "Conservar" },
|
||||
until: { en: "Until", es: "Hasta" },
|
||||
legalHold: { en: "Legal hold", es: "Retención legal" },
|
||||
};
|
||||
|
||||
export const RETENTION_TONE: Record<RetentionMode, "neutral" | "accent" | "live" | "warn" | "danger"> = {
|
||||
export const RETENTION_TONE: Record<FileRetention["mode"], "neutral" | "accent" | "danger"> = {
|
||||
keep: "neutral",
|
||||
until: "accent",
|
||||
legalHold: "danger",
|
||||
};
|
||||
|
||||
export const LEGAL_HOLD_LABEL: LocalizedText = { en: "Legal hold", es: "Retención legal" };
|
||||
|
||||
/** Human label for a MIME class, tolerating an unknown value from the server. */
|
||||
export function mimeClassLabel(mimeClass: MimeClass): LocalizedText {
|
||||
return MIME_CLASS_LABEL[mimeClass] ?? MIME_CLASS_LABEL.other;
|
||||
|
|
|
|||
|
|
@ -3,28 +3,30 @@
|
|||
import { useState } from "react";
|
||||
import { Button, Input } from "../ui/primitives";
|
||||
import { api, errorMessage } from "../../lib/api-client";
|
||||
import { RETENTION_LABEL } from "./labels";
|
||||
import { LEGAL_HOLD_LABEL, RETENTION_LABEL } from "./labels";
|
||||
import { useFilesLang } from "./i18n";
|
||||
import type { GroupFileItem, RetentionInput, RetentionMode } from "./types";
|
||||
import type { GroupFile, RetentionInput } from "./types";
|
||||
|
||||
/**
|
||||
* Admin-only retention change: POST /files/items/:itemId/retention
|
||||
* {mode, until?}. Retention is separate from share expiry — legalHold blocks
|
||||
* deletion, `until` requires a deadline, `keep` is the no-purge default.
|
||||
* Admin-only retention change: POST /files/:id/retention
|
||||
* {mode: keep|until, legalHold?, until?}. Retention is separate from share
|
||||
* expiry — legal hold blocks deletion outright (orthogonal flag), `until`
|
||||
* requires a deadline, `keep` is the no-purge default.
|
||||
*/
|
||||
|
||||
export function RetentionDialog({ item, onClose, onSaved }: {
|
||||
item: GroupFileItem;
|
||||
item: GroupFile;
|
||||
onClose: () => void;
|
||||
onSaved: (itemId: string, input: RetentionInput) => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useFilesLang();
|
||||
const [mode, setMode] = useState<RetentionMode>(item.retentionMode);
|
||||
const [until, setUntil] = useState(item.retentionUntil?.slice(0, 16) ?? "");
|
||||
const [mode, setMode] = useState<"keep" | "until">(item.retention.mode);
|
||||
const [legalHold, setLegalHold] = useState(item.retention.legalHold);
|
||||
const [until, setUntil] = useState(item.retention.expiresAt?.slice(0, 16) ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const modes: RetentionMode[] = ["keep", "until", "legalHold"];
|
||||
const modes: Array<"keep" | "until"> = ["keep", "until"];
|
||||
|
||||
async function save(): Promise<void> {
|
||||
let iso: string | undefined;
|
||||
|
|
@ -43,8 +45,8 @@ export function RetentionDialog({ item, onClose, onSaved }: {
|
|||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: RetentionInput = mode === "until" ? { mode, until: iso } : { mode };
|
||||
await api.post(`/files/items/${item.id}/retention`, body);
|
||||
const body: RetentionInput = { mode, legalHold, ...(mode === "until" && iso !== undefined ? { until: iso } : {}) };
|
||||
await api.post(`/files/${item.id}/retention`, body);
|
||||
onSaved(item.id, body);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
|
|
@ -64,7 +66,7 @@ export function RetentionDialog({ item, onClose, onSaved }: {
|
|||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-sm font-semibold">{t({ en: "Set retention", es: "Definir retención" })}</h2>
|
||||
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
|
||||
<p className="mt-1 truncate text-xs text-text-dim" title={item.name}>{item.name}</p>
|
||||
<p className="mt-3 text-xs leading-relaxed text-text-dim">
|
||||
{t({
|
||||
en: "Retention is independent of share expiry. Legal hold blocks deletion until an admin releases it.",
|
||||
|
|
@ -92,6 +94,15 @@ export function RetentionDialog({ item, onClose, onSaved }: {
|
|||
<Input type="datetime-local" value={until} onChange={(e) => setUntil(e.target.value)} className="mt-1" />
|
||||
</label>
|
||||
)}
|
||||
<label className="mt-3 flex min-h-9 items-center gap-2 text-xs text-text-dim">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={legalHold}
|
||||
onChange={(event) => setLegalHold(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-border accent-accent"
|
||||
/>
|
||||
{t({ ...LEGAL_HOLD_LABEL, en: `${LEGAL_HOLD_LABEL.en} (blocks deletion)`, es: `${LEGAL_HOLD_LABEL.es} (bloquea el borrado)` })}
|
||||
</label>
|
||||
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
|
||||
|
|
|
|||
|
|
@ -3,36 +3,41 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Button, Input } from "../ui/primitives";
|
||||
import { api, errorMessage } from "../../lib/api-client";
|
||||
import { shareIdFromUrl } from "./files-client";
|
||||
import { publicClaimUrl } from "./files-client";
|
||||
import { useFilesLang } from "./i18n";
|
||||
import type { GroupFileItem, ShareResponse } from "./types";
|
||||
import type { GroupFile, ShareCreated } from "./types";
|
||||
|
||||
/**
|
||||
* Mint a public share link for one file: POST /files/items/:itemId/share
|
||||
* {expiresAt}. The token is returned once — after this dialog closes it can
|
||||
* never be recovered, so the result view says so in plain language and offers
|
||||
* a copy button before the user leaves.
|
||||
* Mint a public share link for one file: POST /files/:id/shares
|
||||
* {access: "public", expiresInHours} → ShareCreated. The capability token is
|
||||
* returned once — after this dialog closes it can never be recovered, so the
|
||||
* result view says so in plain language and offers a copy button before the
|
||||
* user leaves. The shareable URL is the public claim endpoint; the library
|
||||
* link in the response stays members-only.
|
||||
*/
|
||||
|
||||
type ExpiryKey = "1h" | "24h" | "7d" | "custom";
|
||||
|
||||
const EXPIRY_MS: Record<Exclude<ExpiryKey, "custom">, number> = {
|
||||
"1h": 60 * 60 * 1000,
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
const EXPIRY_HOURS: Record<Exclude<ExpiryKey, "custom">, number> = {
|
||||
"1h": 1,
|
||||
"24h": 24,
|
||||
"7d": 168,
|
||||
};
|
||||
|
||||
/** The api caps share TTL at 720 hours (30 days). */
|
||||
const MAX_SHARE_HOURS = 720;
|
||||
|
||||
export function ShareDialog({ item, onClose, onCreated }: {
|
||||
item: GroupFileItem;
|
||||
item: GroupFile;
|
||||
onClose: () => void;
|
||||
onCreated: (itemId: string, share: { shareId: string | null; tokenUrl: string }) => void;
|
||||
onCreated: (itemId: string, share: ShareCreated) => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useFilesLang();
|
||||
const [expiry, setExpiry] = useState<ExpiryKey>("24h");
|
||||
const [customAt, setCustomAt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tokenUrl, setTokenUrl] = useState<string | null>(null);
|
||||
const [share, setShare] = useState<ShareCreated | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const options: Array<{ key: ExpiryKey; label: string }> = [
|
||||
|
|
@ -47,27 +52,30 @@ export function ShareDialog({ item, onClose, onCreated }: {
|
|||
return now.toISOString().slice(0, 16);
|
||||
}, []);
|
||||
|
||||
function expiresAtIso(): string | null {
|
||||
function expiresInHours(): number | null {
|
||||
if (expiry === "custom") {
|
||||
if (customAt === "") return null;
|
||||
const parsed = new Date(customAt);
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||
if (!Number.isFinite(parsed.getTime())) return null;
|
||||
const hours = Math.ceil((parsed.getTime() - Date.now()) / 3_600_000);
|
||||
if (hours < 1) return null;
|
||||
return Math.min(hours, MAX_SHARE_HOURS);
|
||||
}
|
||||
return new Date(Date.now() + EXPIRY_MS[expiry]).toISOString();
|
||||
return EXPIRY_HOURS[expiry];
|
||||
}
|
||||
|
||||
async function create(): Promise<void> {
|
||||
const expiresAt = expiresAtIso();
|
||||
if (expiresAt === null) {
|
||||
setError(t({ en: "Pick a future expiry date.", es: "Elige una fecha de caducidad futura." }));
|
||||
const hours = expiresInHours();
|
||||
if (hours === null) {
|
||||
setError(t({ en: "Pick a future expiry date (up to 30 days).", es: "Elige una fecha de caducidad futura (hasta 30 días)." }));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post<ShareResponse>(`/files/items/${item.id}/share`, { expiresAt });
|
||||
setTokenUrl(response.tokenUrl);
|
||||
onCreated(item.id, { shareId: response.shareId ?? shareIdFromUrl(response.tokenUrl), tokenUrl: response.tokenUrl });
|
||||
const created = await api.post<ShareCreated>(`/files/${item.id}/shares`, { access: "public", expiresInHours: hours });
|
||||
setShare(created);
|
||||
onCreated(item.id, created);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -76,9 +84,9 @@ export function ShareDialog({ item, onClose, onCreated }: {
|
|||
}
|
||||
|
||||
async function copy(): Promise<void> {
|
||||
if (tokenUrl === null) return;
|
||||
if (share === null) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(tokenUrl);
|
||||
await navigator.clipboard.writeText(publicClaimUrl(share.token));
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError(t({ en: "Could not copy — select the link and copy it manually.", es: "No se pudo copiar — selecciona el enlace y cópialo a mano." }));
|
||||
|
|
@ -95,9 +103,9 @@ export function ShareDialog({ item, onClose, onCreated }: {
|
|||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-sm font-semibold">{t({ en: "Share file", es: "Compartir archivo" })}</h2>
|
||||
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
|
||||
<p className="mt-1 truncate text-xs text-text-dim" title={item.name}>{item.name}</p>
|
||||
|
||||
{tokenUrl === null ? (
|
||||
{share === null ? (
|
||||
<>
|
||||
<p className="mt-3 text-xs leading-relaxed text-text-dim">
|
||||
{t({
|
||||
|
|
@ -142,7 +150,16 @@ export function ShareDialog({ item, onClose, onCreated }: {
|
|||
es: "Copia este enlace ahora — se muestra solo una vez y no se puede recuperar tras cerrar este cuadro.",
|
||||
})}
|
||||
</p>
|
||||
<Input readOnly value={tokenUrl} aria-label={t({ en: "Share link", es: "Enlace para compartir" })} onFocus={(e) => e.target.select()} className="mt-2 font-mono text-2xs" />
|
||||
<Input
|
||||
readOnly
|
||||
value={publicClaimUrl(share.token)}
|
||||
aria-label={t({ en: "Share link", es: "Enlace para compartir" })}
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="mt-2 font-mono text-2xs"
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-text-faint">
|
||||
{t({ en: "Expires", es: "Caduca" })}: {new Date(share.expiresAt).toLocaleString()}
|
||||
</p>
|
||||
{error !== null && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>{t({ en: "Close", es: "Cerrar" })}</Button>
|
||||
|
|
|
|||
|
|
@ -1,64 +1,68 @@
|
|||
/**
|
||||
* Wire shapes for the group-files HTTP surface (apps/api/src/group-files).
|
||||
* Kept local to the Files feature so it does not collide with the shared
|
||||
* api-types.ts other builders may be editing in parallel. Hand-synced with the
|
||||
* controller: GET /files/groups/:groupId/items returns the flat item below.
|
||||
* api-types.ts other builders may be editing in parallel. Hand-synced with
|
||||
* the controller + the core GroupFile contract.
|
||||
*/
|
||||
|
||||
export type FileOrigin = "matrix" | "bridge" | "upload" | "pad-export";
|
||||
|
||||
/**
|
||||
* Coarse MIME class. NOTE: the API's DB CHECK (migration 029) and
|
||||
* group-files.validation.ts MIME_CLASSES both use
|
||||
* "image" | "video" | "audio" | "document" | "other" — the task brief said
|
||||
* image/video/audio/file/doc; this follows the backend's real enum.
|
||||
*/
|
||||
export type MimeClass = "image" | "video" | "audio" | "document" | "other";
|
||||
export type MimeClass = "image" | "video" | "audio" | "document" | "archive" | "other";
|
||||
|
||||
export type RetentionMode = "keep" | "until" | "legalHold";
|
||||
export type FileStatus = "available" | "revoked" | "expired";
|
||||
|
||||
/** One row of the group file library — the served item shape. */
|
||||
export interface GroupFileItem {
|
||||
/** Retention is separate from share expiry: legalHold blocks deletion, `until` sets a deadline. */
|
||||
export interface FileRetention {
|
||||
mode: "keep" | "until";
|
||||
legalHold: boolean;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/** One row of the group file library — the served item shape (core GroupFile). */
|
||||
export interface GroupFile {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
groupId: string;
|
||||
origin: FileOrigin;
|
||||
senderMxid: string;
|
||||
originalName: string;
|
||||
safeName: string;
|
||||
mime: string;
|
||||
mimeClass: MimeClass;
|
||||
byteSize: number;
|
||||
sha256: string;
|
||||
roomId: string | null;
|
||||
senderId: string;
|
||||
eventId: string | null;
|
||||
origin: { kind: FileOrigin };
|
||||
name: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
checksum: { value: string };
|
||||
createdAt: string;
|
||||
available: boolean;
|
||||
retentionMode: RetentionMode;
|
||||
retentionUntil: string | null;
|
||||
indexedAt: string;
|
||||
file: { resourceId: string };
|
||||
shareLink: { url: string; access: "members" | "public"; expiresAt: string | null };
|
||||
retention: FileRetention;
|
||||
status: FileStatus;
|
||||
}
|
||||
|
||||
export interface GroupFilePage {
|
||||
items: GroupFileItem[];
|
||||
items: GroupFile[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** POST /files/:id/shares response. `token` is the public capability — shown once. */
|
||||
export interface ShareCreated {
|
||||
id: string;
|
||||
itemId: string;
|
||||
access: "members" | "public";
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** POST /files/:id/retention body. */
|
||||
export interface RetentionInput {
|
||||
mode: "keep" | "until";
|
||||
legalHold?: boolean;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
/** GET /groups/mine (same shape the More page already consumes). */
|
||||
export interface GroupSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /files/items/:itemId/share response. The task surface documents only
|
||||
* `tokenUrl`; the API may also return a `shareId` used by
|
||||
* DELETE /files/shares/:shareId. When absent the client derives the id from
|
||||
* the tokenUrl's path segment so revoke still works.
|
||||
*/
|
||||
export interface ShareResponse {
|
||||
tokenUrl: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
/** POST /files/items/:itemId/retention body. */
|
||||
export interface RetentionInput {
|
||||
mode: RetentionMode;
|
||||
until?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Loading } from "../brand/loading";
|
|||
import { apiUploadFile, errorMessage } from "../../lib/api-client";
|
||||
import { formatBytes } from "../video/format";
|
||||
import { useFilesLang } from "./i18n";
|
||||
import type { GroupFileItem } from "./types";
|
||||
import type { GroupFile } from "./types";
|
||||
|
||||
/**
|
||||
* Drag-drop + picker for POST /files/groups/:groupId/upload. The API accepts a
|
||||
|
|
@ -19,7 +19,7 @@ const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|||
export function UploadDropzone({ groupId, live, onUploaded }: {
|
||||
groupId: string;
|
||||
live: boolean;
|
||||
onUploaded: (item: GroupFileItem) => void;
|
||||
onUploaded: (item: GroupFile) => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useFilesLang();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -52,7 +52,7 @@ export function UploadDropzone({ groupId, live, onUploaded }: {
|
|||
setProgress(0);
|
||||
setUploading(`${file.name} (${formatBytes(file.size)})`);
|
||||
try {
|
||||
const created = await apiUploadFile<GroupFileItem>(
|
||||
const created = await apiUploadFile<GroupFile>(
|
||||
`/files/groups/${encodeURIComponent(groupId)}/upload`,
|
||||
file,
|
||||
setProgress,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue