Add group cloud: files index api, web UI, archive sync bot
The Telegram-like group file contract (packages/core shared-files.ts) becomes a working subsystem, built in three parallel tracks: API (NestJS): migration 029 group_files (chat_media_archive metadata-only index with partial replay-unique + RLS, group_file_shares with sha256 token persistence, archive_sync_state/outbox durable-claim seam incl. a unique claim_key index), group-files module (list/search with cursor pagination, direct upload ≤20 MiB streamed+hashed to oCIS WebDAV, download with ACL + retention rechecks, share links with expiry, retention modes incl. legalHold) — RBAC group-membership authz + audit chain events. Web: Files section per group — group picker, drag-drop upload (20 MiB cap), search + MIME/origin filters, cursor pagination, download via authenticated blob fetch, share dialog with expiry + shown-once token, admin retention dialog; bilingual EN/ES (wiki i18n pattern); nav sidebar/palette/bottom-nav wiring; MIME classes aligned to the DB CHECK (document/other). Archive bot (apps/archive-bot): plain sync-loop Matrix bot mirroring attachments from explicitly-mapped unencrypted archive rooms — raw /sync with durable since-cursor, outbox-claimed ingestion (hash-verified stream, no temp files), magic-byte MIME sniffing, safe-name normalization, WebDAV upload, indexed + replied with link, redaction→unavailable; health endpoint; Dockerfile. Tests 74/74, typecheck clean across api/web/bot.
This commit is contained in:
parent
eb1b60f67d
commit
d58db63fe6
58 changed files with 5543 additions and 4 deletions
13
apps/web/src/app/(main)/files/layout.tsx
Normal file
13
apps/web/src/app/(main)/files/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { FilesLangProvider } from "../../../components/files/i18n";
|
||||
|
||||
/**
|
||||
* Files shell. Like the wiki, the language lives in localStorage per browser
|
||||
* (lang-context explains why it is not an account setting) and is provided here
|
||||
* so every Files screen and sub-component reads it through one context.
|
||||
*/
|
||||
export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return <FilesLangProvider>{children}</FilesLangProvider>;
|
||||
}
|
||||
383
apps/web/src/app/(main)/files/page.tsx
Normal file
383
apps/web/src/app/(main)/files/page.tsx
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Input } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { FileRow } from "../../../components/files/file-row";
|
||||
import { UploadDropzone } from "../../../components/files/upload-dropzone";
|
||||
import { ShareDialog } from "../../../components/files/share-dialog";
|
||||
import { RetentionDialog } from "../../../components/files/retention-dialog";
|
||||
import { downloadGroupFile } from "../../../components/files/files-client";
|
||||
import { useFilesLang } from "../../../components/files/i18n";
|
||||
import { MIME_CLASS_LABEL, ORIGIN_LABEL } from "../../../components/files/labels";
|
||||
import type {
|
||||
FileOrigin,
|
||||
GroupFileItem,
|
||||
GroupFilePage,
|
||||
GroupSummary,
|
||||
MimeClass,
|
||||
RetentionInput,
|
||||
} from "../../../components/files/types";
|
||||
|
||||
/**
|
||||
* Group file library — the per-group "group cloud". Live mode pages through
|
||||
* GET /files/groups/:groupId/items by cursor (video library's load-more
|
||||
* pattern) with a debounced server-side `q` and mimeClass/origin filters.
|
||||
* Offline the screen renders sample data read-only per useApiResource rules.
|
||||
*/
|
||||
|
||||
const PAGE_LIMIT = 50;
|
||||
|
||||
const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }];
|
||||
const SAMPLE_FILES: GroupFileItem[] = [
|
||||
{
|
||||
id: "sample-1", groupId: "sample-group", origin: "matrix", senderMxid: "@ada:collective.example",
|
||||
originalName: "press-kit.zip", safeName: "press-kit.zip", mime: "application/zip", mimeClass: "other",
|
||||
byteSize: 4_812_441, sha256: "a".repeat(64), createdAt: "2026-10-08T09:12:00.000Z", available: true,
|
||||
retentionMode: "keep", retentionUntil: null,
|
||||
},
|
||||
{
|
||||
id: "sample-2", groupId: "sample-group", origin: "upload", senderMxid: "@you:collective.example",
|
||||
originalName: "launch-banner.png", safeName: "launch-banner.png", mime: "image/png", mimeClass: "image",
|
||||
byteSize: 348_112, sha256: "b".repeat(64), createdAt: "2026-10-08T11:40:00.000Z", available: true,
|
||||
retentionMode: "until", retentionUntil: "2026-11-30T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "sample-3", groupId: "sample-group", origin: "bridge", senderMxid: "@bridge:collective.example",
|
||||
originalName: "statement-final.pdf", safeName: "statement-final.pdf", mime: "application/pdf", mimeClass: "document",
|
||||
byteSize: 1_204_998, sha256: "c".repeat(64), createdAt: "2026-10-07T18:05:00.000Z", available: true,
|
||||
retentionMode: "legalHold", retentionUntil: null,
|
||||
},
|
||||
];
|
||||
|
||||
const identityGroups = (groups: GroupSummary[]): GroupSummary[] => groups;
|
||||
|
||||
export default function FilesPage(): React.ReactElement {
|
||||
const { t, lang, setLang } = useFilesLang();
|
||||
const { status: authStatus, can, session } = useAuth();
|
||||
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
|
||||
|
||||
const [pickedGroup, setPickedGroup] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<GroupFileItem[]>([]);
|
||||
const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [mimeClass, setMimeClass] = useState<"" | MimeClass>("");
|
||||
const [origin, setOrigin] = useState<"" | FileOrigin>("");
|
||||
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [shareFor, setShareFor] = useState<GroupFileItem | null>(null);
|
||||
const [retentionFor, setRetentionFor] = useState<GroupFileItem | null>(null);
|
||||
const [shares, setShares] = useState<Record<string, { shareId: string | null; tokenUrl: string }>>({});
|
||||
|
||||
const groupId = pickedGroup ?? groups.data[0]?.id ?? "";
|
||||
const live = source === "live";
|
||||
const isAdmin = can("workspace.members.manage") || session?.role === "Admin" || session?.role === "Owner";
|
||||
|
||||
// Debounce the search box; only the settled value drives the server query.
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setQ(qInput.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [qInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus === "loading") return;
|
||||
if (authStatus !== "authenticated") {
|
||||
setItems(SAMPLE_FILES);
|
||||
setNextCursor(null);
|
||||
setSource("sample");
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (groupId === "") {
|
||||
setItems([]);
|
||||
setNextCursor(null);
|
||||
setSource("live");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
api
|
||||
.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, { q, mimeClass, origin, limit: PAGE_LIMIT }, controller.signal)
|
||||
.then((page) => {
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
setSource("live");
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof ApiUnreachableError) {
|
||||
setItems(SAMPLE_FILES);
|
||||
setNextCursor(null);
|
||||
setSource("sample");
|
||||
return;
|
||||
}
|
||||
setSource("live");
|
||||
setError(errorMessage(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [authStatus, groupId, q, mimeClass, origin, nonce]);
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (nextCursor === null || groupId === "") return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await api.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, {
|
||||
q, mimeClass, origin, limit: PAGE_LIMIT, cursor: nextCursor,
|
||||
});
|
||||
setItems((current) => {
|
||||
const seen = new Set(current.map((item) => item.id));
|
||||
return [...current, ...page.items.filter((item) => !seen.has(item.id))];
|
||||
});
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (err) {
|
||||
setActionError(errorMessage(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploaded = useCallback((item: GroupFileItem): void => {
|
||||
setItems((current) => [item, ...current.filter((existing) => existing.id !== item.id)]);
|
||||
setNotice(t({ en: "File uploaded.", es: "Archivo subido." }));
|
||||
}, [t]);
|
||||
|
||||
async function handleDownload(item: GroupFileItem): Promise<void> {
|
||||
setActionError(null);
|
||||
try {
|
||||
await downloadGroupFile(item.id, item.safeName);
|
||||
} catch (err) {
|
||||
setActionError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeShare(item: GroupFileItem): Promise<void> {
|
||||
const entry = shares[item.id];
|
||||
if (entry === undefined || entry.shareId === null || entry.shareId === "") {
|
||||
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." }));
|
||||
return;
|
||||
}
|
||||
const previous = shares;
|
||||
setBusyId(item.id);
|
||||
setActionError(null);
|
||||
// Optimistic: drop the link immediately, restore if the API rejects.
|
||||
setShares((current) => {
|
||||
const next = { ...current };
|
||||
delete next[item.id];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await api.del(`/files/shares/${encodeURIComponent(entry.shareId)}`);
|
||||
setNotice(t({ en: "Share link revoked.", es: "Enlace para compartir revocado." }));
|
||||
} catch (err) {
|
||||
setShares(previous);
|
||||
setActionError(errorMessage(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetentionSaved(itemId: string, input: RetentionInput): void {
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
item.id === itemId ? { ...item, retentionMode: input.mode, retentionUntil: input.until ?? null } : item,
|
||||
),
|
||||
);
|
||||
setNotice(t({ en: "Retention updated.", es: "Retención actualizada." }));
|
||||
}
|
||||
|
||||
const groupChosen = groupId !== "";
|
||||
const showList = source !== "loading";
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
|
||||
<div className="mb-5 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">{t({ en: "Files", es: "Archivos" })}</h1>
|
||||
<OfflineBadge source={source} />
|
||||
{groups.data.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-xs text-text-dim">
|
||||
<span className="sr-only">{t({ en: "Group", es: "Grupo" })}</span>
|
||||
<select
|
||||
aria-label={t({ en: "Group", es: "Grupo" })}
|
||||
value={groupId}
|
||||
onChange={(event) => setPickedGroup(event.target.value)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
>
|
||||
{groups.data.map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Language">
|
||||
{(["en", "es"] as const).map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => setLang(code)}
|
||||
aria-pressed={lang === code}
|
||||
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
|
||||
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-text-dim">
|
||||
{t({
|
||||
en: "Files shared with this group, from Matrix rooms, bridges, direct uploads and pad exports. Download and share links stream the verified bytes back through the hub — nothing is stored on a third party.",
|
||||
es: "Archivos compartidos con este grupo, desde salas Matrix, puentes, subidas directas y exportaciones de pad. La descarga y los enlaces de compartir sirven los bytes verificados a través del hub — nada queda en terceros.",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{groups.error !== null && <p className="mb-3 text-sm text-danger">{groups.error}</p>}
|
||||
{error !== null && <p className="mb-3 text-sm text-danger">{error}</p>}
|
||||
{actionError !== null && <p role="alert" className="mb-3 text-sm text-danger">{actionError}</p>}
|
||||
{notice !== null && <p role="status" className="mb-3 text-sm text-live">{notice}</p>}
|
||||
|
||||
<UploadDropzone groupId={groupId} live={live} onUploaded={handleUploaded} />
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="search"
|
||||
value={qInput}
|
||||
onChange={(event) => setQInput(event.target.value)}
|
||||
placeholder={t({ en: "Search files by name…", es: "Buscar archivos por nombre…" })}
|
||||
aria-label={t({ en: "Search files", es: "Buscar archivos" })}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<select
|
||||
aria-label={t({ en: "MIME class", es: "Clase MIME" })}
|
||||
value={mimeClass}
|
||||
onChange={(event) => setMimeClass(event.target.value as "" | MimeClass)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">{t({ en: "All types", es: "Todos los tipos" })}</option>
|
||||
{(Object.keys(MIME_CLASS_LABEL) as MimeClass[]).map((value) => (
|
||||
<option key={value} value={value}>{t(MIME_CLASS_LABEL[value])}</option>
|
||||
))}
|
||||
</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 && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setNonce((n) => n + 1)} disabled={loading}>
|
||||
{t({ en: "Refresh", es: "Actualizar" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.data.length === 0 && source !== "sample" && (
|
||||
<EmptyState
|
||||
title={t({ en: "No groups yet", es: "Aún no hay grupos" })}
|
||||
hint={t({ en: "Files live inside a group. Join or create one, then come back.", es: "Los archivos viven dentro de un grupo. Únete o crea uno y vuelve." })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{groups.data.length > 0 && !groupChosen && source !== "sample" && (
|
||||
<EmptyState
|
||||
title={t({ en: "Choose a group", es: "Elige un grupo" })}
|
||||
hint={t({ en: "Pick a group above to see its shared files.", es: "Elige un grupo arriba para ver sus archivos compartidos." })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{source === "loading" && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-faint">
|
||||
<Loading size={16} label={t({ en: "Loading files", es: "Cargando archivos" })} />
|
||||
{t({ en: "Loading files…", es: "Cargando archivos…" })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showList && loading && items.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2 text-2xs text-text-faint">
|
||||
<Loading size={12} label={t({ en: "Refreshing", es: "Actualizando" })} />
|
||||
{t({ en: "Refreshing…", es: "Actualizando…" })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showList && !loading && (groupChosen || source === "sample") && items.length === 0 && (
|
||||
<EmptyState
|
||||
title={t({ en: "No files here yet", es: "Aún no hay archivos aquí" })}
|
||||
hint={
|
||||
q !== "" || mimeClass !== "" || origin !== ""
|
||||
? 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." })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showList && items.length > 0 && (
|
||||
<div className="space-y-2.5">
|
||||
{items.map((item) => (
|
||||
<FileRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
live={live}
|
||||
canRetention={isAdmin}
|
||||
busy={busyId === item.id}
|
||||
hasActiveShare={shares[item.id] !== undefined}
|
||||
onDownload={() => void handleDownload(item)}
|
||||
onShare={() => { setActionError(null); setShareFor(item); }}
|
||||
onRevoke={() => void revokeShare(item)}
|
||||
onRetention={() => { setActionError(null); setRetentionFor(item); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{live && nextCursor !== null && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadMore()}>
|
||||
{loadingMore ? t({ en: "Loading…", es: "Cargando…" }) : t({ en: "Load more", es: "Cargar más" })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-2xs leading-relaxed text-text-faint">
|
||||
{t({
|
||||
en: "Retention (keep / until / legal hold) is separate from link expiry: revoking or expiring a share never deletes the file, and legal hold blocks deletion until an admin releases it.",
|
||||
es: "La retención (conservar / hasta / retención legal) es independiente de la caducidad del enlace: revocar o dejar caducar un enlace nunca borra el archivo, y la retención legal bloquea el borrado hasta que un admin la libere.",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{shareFor !== null && (
|
||||
<ShareDialog
|
||||
item={shareFor}
|
||||
onClose={() => setShareFor(null)}
|
||||
onCreated={(itemId, share) => setShares((current) => ({ ...current, [itemId]: share }))}
|
||||
/>
|
||||
)}
|
||||
{retentionFor !== null && (
|
||||
<RetentionDialog item={retentionFor} onClose={() => setRetentionFor(null)} onSaved={handleRetentionSaved} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
|
|||
const titles: Record<string, string> = {
|
||||
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
|
||||
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
|
||||
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive",
|
||||
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files",
|
||||
};
|
||||
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
|
||||
const scrollPositions = new Map<string, { main: number; nested: number[] }>();
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export default function MorePage(): React.ReactElement {
|
|||
<Row href="/calendar" title="Calendar" />
|
||||
<Row href="/analytics" title="Analytics" />
|
||||
<Row href="/video" title="Video library" />
|
||||
<Row href="/files" title="Files" detail="Group file library" />
|
||||
<Row href="/onboarding" title="Onboarding" />
|
||||
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue