feat(PROJ-11): build team chat via direct browser-to-Synapse connection

Frontend: matrix-js-sdk client per opened team, /sync against Synapse
directly (no chat traffic proxied through the backend), timeline with
day-grouping and read receipts, history backfill, lazy-loaded chat tab
in TeamsPage's team detail view (keeps matrix-js-sdk out of the initial
bundle).

Backend: POST /api/chat/token mints/caches a short-lived per-member
Matrix access token via Synapse's admin puppet-login (1h TTL, in-memory
cache with 5min pre-expiry refresh, concurrent-request dedup, audited
on real mints only); GET /api/chat/room resolves a team's Matrix room.
Cached tokens are invalidated immediately on member suspend/leave.

Fixes found while wiring this up:
- createRoom() now explicitly sets history_visibility=shared (PROJ-9)
  instead of relying on the private_chat preset's implicit default.
- POST /api/organizations never provisioned a Matrix account for the
  org creator (only the invitation-accept path did) — they'd have
  seen "Chat wird eingerichtet" forever. Now enqueues the same
  provision_matrix_user job invitations use.

Open: SYNAPSE_PUBLIC_BASE_URL needs documenting in backend/.env.example
(blocked by local permission rules on that file — added manually on
the talkhub test host instead), per-tile unread counts need an
app-level Matrix client instead of one per opened team, and Synapse
needs TLS + public reachability before this works outside a LAN.

Verified end-to-end on the talkhub/matrix test LXCs: registered a
throwaway account, created org → department → season → team, joined
the team, minted a token, sent a message directly to Synapse, read it
back from room history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-11 19:09:24 +02:00
1 parent 8b6114c3e8
commit 7fa658b4ff
17 files changed
+1205 -7

No files matched your search

+160 -2
View File
@@ -1,11 +1,11 @@
{
"name": "ai-coding-starter-kit-frontend",
"name": "talktime-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-coding-starter-kit-frontend",
"name": "talktime-frontend",
"version": "1.0.0",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -33,6 +33,7 @@
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
"lucide-react": "^0.562.0",
"matrix-js-sdk": "^42.3.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -302,6 +303,12 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.5.tgz",
"integrity": "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg==",
"license": "MIT"
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1224,6 +1231,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "18.8.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.8.0.tgz",
"integrity": "sha512-J+bYzUqtkYSrIUbDT8Razf9F1gzlpC94qeC5LQlr9QmjrzyaErwlV9v7S+gtSOeDvJCdfP8ebEDaANHiVLCYQg==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -2828,6 +2844,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/events": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2927,6 +2949,12 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/another-json": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
"license": "Apache-2.0"
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -3034,6 +3062,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/base-x": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
@@ -3118,6 +3152,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bs58": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
"license": "MIT",
"dependencies": {
"base-x": "^5.0.0"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3288,6 +3331,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -3649,6 +3705,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3988,6 +4053,18 @@
"node": ">=0.10.0"
}
},
"node_modules/is-network-error": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
"integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -4160,6 +4237,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4179,6 +4269,44 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/matrix-events-sdk": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "42.3.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-42.3.0.tgz",
"integrity": "sha512-jXXFLlaA25zQ2oxXCgbAEubGC8KpF8OabSf387W0RXfkwqhNXEbU+veeUaZMT+bCbyE5nDxkN3iBy038VbntQg==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^8.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.4.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^2.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.18.0",
"p-retry": "8",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/matrix-widget-api": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.19.0.tgz",
"integrity": "sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
"events": "^3.2.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4361,6 +4489,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.1.tgz",
"integrity": "sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.3.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4939,6 +5082,15 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -5245,6 +5397,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/unhomoglyph": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
+1
View File
@@ -35,6 +35,7 @@
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
"lucide-react": "^0.562.0",
"matrix-js-sdk": "^42.3.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -0,0 +1,55 @@
import { useState, type FormEvent, type KeyboardEvent } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface MessageComposerProps {
disabled: boolean;
sending: boolean;
onSend: (body: string) => Promise<void>;
}
export function MessageComposer({ disabled, sending, onSend }: MessageComposerProps) {
const [value, setValue] = useState("");
async function submit() {
const body = value.trim();
if (!body || disabled || sending) return;
setValue("");
try {
await onSend(body);
} catch {
// Keep the text so nothing is lost when sending failed.
setValue(body);
}
}
function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submit();
}
}
function onSubmit(event: FormEvent) {
event.preventDefault();
void submit();
}
return (
<form onSubmit={onSubmit} className="flex items-end gap-2 border-t p-3">
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
disabled={disabled}
rows={1}
placeholder="Nachricht schreiben…"
aria-label="Nachricht schreiben"
className="max-h-32 min-h-[40px] resize-none"
/>
<Button type="submit" disabled={disabled || sending || !value.trim()}>
{sending ? "Sendet…" : "Senden"}
</Button>
</form>
);
}
@@ -0,0 +1,133 @@
import { useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { formatDayLabel, formatTime } from "@/lib/matrix";
import type { ChatMessage } from "@/hooks/use-team-chat";
interface MessageListProps {
messages: ChatMessage[];
ownUserId: string | null;
loading: boolean;
canLoadMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
}
interface DayGroup {
key: string;
label: string;
messages: ChatMessage[];
}
function groupByDay(messages: ChatMessage[]): DayGroup[] {
const groups: DayGroup[] = [];
for (const message of messages) {
const date = new Date(message.timestamp);
const key = date.toDateString();
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.messages.push(message);
} else {
groups.push({ key, label: formatDayLabel(date), messages: [message] });
}
}
return groups;
}
export function MessageList({
messages,
ownUserId,
loading,
canLoadMore,
loadingMore,
onLoadMore,
}: MessageListProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const groups = useMemo(() => groupByDay(messages), [messages]);
const lastId = messages[messages.length - 1]?.id;
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [lastId]);
if (loading) {
return (
<div className="space-y-3 p-4" aria-busy="true" aria-label="Nachrichten werden geladen">
<Skeleton className="h-12 w-2/3" />
<Skeleton className="h-12 w-1/2" />
<Skeleton className="h-12 w-3/5" />
</div>
);
}
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center p-6">
<p className="text-sm text-muted-foreground">
Noch keine Nachrichten. Schreib die erste.
</p>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4" role="log" aria-live="polite" aria-label="Chatverlauf">
{canLoadMore && (
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={loadingMore}>
{loadingMore ? "Lädt…" : "Ältere Nachrichten laden"}
</Button>
</div>
)}
{groups.map((group) => (
<section key={group.key} className="space-y-2">
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<h3 className="text-xs font-medium text-muted-foreground">{group.label}</h3>
<span className="h-px flex-1 bg-border" />
</div>
{group.messages.map((message) => {
const own = message.senderId === ownUserId;
return (
<article
key={message.id}
className={cn("flex flex-col", own ? "items-end" : "items-start")}
>
<div
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 sm:max-w-[70%]",
own ? "bg-primary text-primary-foreground" : "bg-muted",
message.pending && "opacity-60",
)}
>
{!own && (
<p className="text-xs font-medium opacity-80">{message.senderName}</p>
)}
<p className="whitespace-pre-wrap break-words text-sm">{message.body}</p>
<p className="mt-1 text-[10px] opacity-70">
<time dateTime={new Date(message.timestamp).toISOString()}>
{formatTime(new Date(message.timestamp))}
</time>
{message.pending && " · wird gesendet…"}
</p>
</div>
{own && message.readBy.length > 0 && (
<p className="mt-0.5 text-[10px] text-muted-foreground">
gelesen von: {message.readBy.join(", ")}
</p>
)}
</article>
);
})}
</section>
))}
<div ref={bottomRef} />
</div>
</ScrollArea>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import type { useTeamChat } from "@/hooks/use-team-chat";
import { MessageComposer } from "./MessageComposer";
import { MessageList } from "./MessageList";
interface TeamChatProps {
/** Lifted into the parent so the unread badge can live on the tab trigger. */
chat: ReturnType<typeof useTeamChat>;
teamName: string;
}
export function TeamChat({ chat, teamName }: TeamChatProps) {
const { status, markRead, messages } = chat;
const lastId = messages[messages.length - 1]?.id;
useEffect(() => {
if (status === "ready" && lastId) {
markRead();
}
}, [status, lastId, markRead]);
if (status === "pending") {
const setupText =
chat.pendingReason === "room_not_provisioned"
? "Der Gruppenraum für dieses Team wird gerade angelegt. Sobald er bereit ist, erscheint der Verlauf hier automatisch."
: "Dein Chat-Zugang wird gerade eingerichtet. Das dauert in der Regel nur kurz — diese Seite aktualisiert sich automatisch.";
return (
<Alert>
<AlertTitle>
{chat.pendingReason === "room_not_provisioned"
? "Raum wird eingerichtet"
: "Chat wird eingerichtet"}
</AlertTitle>
<AlertDescription>{setupText}</AlertDescription>
</Alert>
);
}
if (status === "error") {
return (
<Alert variant="destructive">
<AlertTitle>Chat nicht verfügbar</AlertTitle>
<AlertDescription className="space-y-3">
<p>{chat.error ?? "Verbindung zum Chat-Server fehlgeschlagen."}</p>
<Button variant="outline" size="sm" onClick={chat.retry}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div
className="flex h-[60vh] flex-col rounded-lg border sm:h-[65vh]"
aria-label={`Chat für ${teamName}`}
>
<div className="min-h-0 flex-1">
<MessageList
messages={chat.messages}
ownUserId={chat.ownUserId}
loading={status === "loading"}
canLoadMore={chat.canLoadMore}
loadingMore={chat.loadingMore}
onLoadMore={() => void chat.loadMore()}
/>
</div>
{chat.error && status === "ready" && (
<p className="px-3 pb-1 text-xs text-destructive">{chat.error}</p>
)}
<MessageComposer
disabled={status !== "ready"}
sending={chat.sending}
onSend={chat.sendMessage}
/>
</div>
);
}
@@ -0,0 +1,71 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/lib/auth-context";
import { useTeamChat } from "@/hooks/use-team-chat";
import { TeamChat } from "./TeamChat";
interface TeamDetailProps {
teamId: string;
teamName: string;
departmentName: string;
seasonName: string;
onClose: () => void;
}
export function TeamDetail({
teamId,
teamName,
departmentName,
seasonName,
onClose,
}: TeamDetailProps) {
const { token } = useAuth();
const chat = useTeamChat(token, teamId);
return (
<Card>
<CardContent className="space-y-4 pt-6">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{teamName}</h2>
<p className="text-xs text-muted-foreground">
{departmentName} · {seasonName}
</p>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
Schließen
</Button>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Übersicht</TabsTrigger>
<TabsTrigger value="chat" className="gap-2">
Chat
{chat.unreadCount > 0 && (
<Badge variant="secondary" aria-label={`${chat.unreadCount} ungelesene Nachrichten`}>
{chat.unreadCount}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="pt-4">
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Abteilung</dt>
<dd>{departmentName}</dd>
<dt className="text-muted-foreground">Saison</dt>
<dd>{seasonName}</dd>
</dl>
</TabsContent>
<TabsContent value="chat" className="pt-4">
<TeamChat chat={chat} teamName={teamName} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
ClientEvent,
Direction,
EventType,
RoomEvent,
SyncState,
type MatrixClient,
type MatrixEvent,
type Room,
} from "matrix-js-sdk";
import {
ChatSetupPendingError,
createMatrixClient,
fetchChatCredentials,
fetchTeamRoomId,
type ChatSetupReason,
} from "@/lib/matrix";
export interface ChatMessage {
id: string;
senderId: string;
senderName: string;
body: string;
timestamp: number;
pending: boolean;
readBy: string[];
}
export type ChatStatus = "loading" | "pending" | "error" | "ready";
interface UseTeamChatResult {
status: ChatStatus;
pendingReason: ChatSetupReason | null;
error: string | null;
messages: ChatMessage[];
ownUserId: string | null;
unreadCount: number;
canLoadMore: boolean;
loadingMore: boolean;
sending: boolean;
sendMessage: (body: string) => Promise<void>;
loadMore: () => Promise<void>;
markRead: () => void;
retry: () => void;
}
const POLL_INTERVAL_MS = 15_000;
const PAGE_SIZE = 30;
function readReceiptsFor(room: Room, eventId: string, ownUserId: string): string[] {
const event = room.findEventById(eventId);
if (!event) return [];
return room
.getReceiptsForEvent(event)
.filter((r) => r.type === "m.read" && r.userId !== ownUserId)
.map((r) => room.getMember(r.userId)?.name ?? r.userId);
}
function toMessages(room: Room, ownUserId: string): ChatMessage[] {
return room
.getLiveTimeline()
.getEvents()
.filter((event) => event.getType() === EventType.RoomMessage && !event.isRedacted())
.map((event) => {
const id = event.getId() ?? `${event.getTs()}`;
return {
id,
senderId: event.getSender() ?? "",
senderName: room.getMember(event.getSender() ?? "")?.name ?? event.getSender() ?? "",
body: (event.getContent().body as string) ?? "",
timestamp: event.getTs(),
pending: event.status !== null,
readBy: readReceiptsFor(room, id, ownUserId),
};
});
}
export function useTeamChat(authToken: string | null, teamId: string | null): UseTeamChatResult {
const [status, setStatus] = useState<ChatStatus>("loading");
const [pendingReason, setPendingReason] = useState<ChatSetupReason | null>(null);
const [error, setError] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [ownUserId, setOwnUserId] = useState<string | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [canLoadMore, setCanLoadMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [sending, setSending] = useState(false);
const [attempt, setAttempt] = useState(0);
const clientRef = useRef<MatrixClient | null>(null);
const roomRef = useRef<Room | null>(null);
const refresh = useCallback(() => {
const room = roomRef.current;
const userId = clientRef.current?.getUserId();
if (!room || !userId) return;
setMessages(toMessages(room, userId));
setUnreadCount(room.getUnreadNotificationCount() ?? 0);
setCanLoadMore(Boolean(room.getLiveTimeline().getPaginationToken(Direction.Backward)));
}, []);
useEffect(() => {
if (!authToken || !teamId) {
setStatus("loading");
return;
}
let cancelled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let client: MatrixClient | null = null;
const onTimeline = () => !cancelled && refresh();
async function connect() {
setStatus("loading");
setError(null);
setPendingReason(null);
try {
const roomId = await fetchTeamRoomId(authToken!, teamId!);
const creds = await fetchChatCredentials(authToken!);
if (cancelled) return;
client = createMatrixClient(creds);
clientRef.current = client;
setOwnUserId(creds.userId);
client.on(RoomEvent.Timeline, onTimeline);
client.on(RoomEvent.Receipt, onTimeline);
client.on(RoomEvent.LocalEchoUpdated, onTimeline);
client.on(ClientEvent.Sync, (state: SyncState) => {
if (cancelled) return;
if (state === SyncState.Prepared || state === SyncState.Syncing) {
const room = client!.getRoom(roomId);
if (!room) {
// Member not (yet) joined to the room — provisioning still running.
setStatus("pending");
setPendingReason("room_not_provisioned");
return;
}
roomRef.current = room;
setStatus("ready");
refresh();
} else if (state === SyncState.Error) {
setStatus("error");
setError("Verbindung zum Chat-Server fehlgeschlagen.");
}
});
await client.startClient({ initialSyncLimit: PAGE_SIZE });
} catch (err) {
if (cancelled) return;
if (err instanceof ChatSetupPendingError) {
setStatus("pending");
setPendingReason(err.reason);
// Poll instead of erroring until provisioning caught up.
pollTimer = setTimeout(() => void connect(), POLL_INTERVAL_MS);
return;
}
setStatus("error");
setError((err as Error).message);
}
}
void connect();
return () => {
cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
if (client) {
client.removeAllListeners();
client.stopClient();
}
clientRef.current = null;
roomRef.current = null;
};
}, [authToken, teamId, attempt, refresh]);
const sendMessage = useCallback(
async (body: string) => {
const client = clientRef.current;
const room = roomRef.current;
if (!client || !room || !body.trim()) return;
setSending(true);
setError(null);
try {
await client.sendTextMessage(room.roomId, body.trim());
} catch (err) {
// Expired token: one automatic retry with fresh credentials, then surface the error.
try {
const creds = await fetchChatCredentials(authToken!, { refresh: true });
client.setAccessToken(creds.accessToken);
await client.sendTextMessage(room.roomId, body.trim());
} catch {
setError((err as Error).message || "Nachricht konnte nicht gesendet werden.");
throw err;
}
} finally {
setSending(false);
refresh();
}
},
[authToken, refresh],
);
const loadMore = useCallback(async () => {
const client = clientRef.current;
const room = roomRef.current;
if (!client || !room || loadingMore) return;
setLoadingMore(true);
try {
await client.paginateEventTimeline(room.getLiveTimeline(), {
backwards: true,
limit: PAGE_SIZE,
});
refresh();
} catch (err) {
setError((err as Error).message);
} finally {
setLoadingMore(false);
}
}, [loadingMore, refresh]);
const markRead = useCallback(() => {
const client = clientRef.current;
const room = roomRef.current;
if (!client || !room) return;
const events = room.getLiveTimeline().getEvents();
const last: MatrixEvent | undefined = events[events.length - 1];
if (last) {
void client.sendReadReceipt(last).catch(() => undefined);
setUnreadCount(0);
}
}, []);
const retry = useCallback(() => setAttempt((n) => n + 1), []);
return {
status,
pendingReason,
error,
messages,
ownUserId,
unreadCount,
canLoadMore,
loadingMore,
sending,
sendMessage,
loadMore,
markRead,
retry,
};
}
+97
View File
@@ -0,0 +1,97 @@
import { createClient, type MatrixClient } from "matrix-js-sdk";
export interface ChatCredentials {
homeserverUrl: string;
userId: string;
accessToken: string;
expiresInMs: number;
}
export type ChatSetupReason = "matrix_not_provisioned" | "room_not_provisioned";
export class ChatSetupPendingError extends Error {
constructor(public readonly reason: ChatSetupReason) {
super(reason);
this.name = "ChatSetupPendingError";
}
}
async function readError(res: Response): Promise<string> {
try {
const body = (await res.json()) as { error?: string };
return body.error ?? `Fehler (${res.status})`;
} catch {
return `Fehler (${res.status})`;
}
}
/** Asks the TalkTime backend for a short-lived Matrix access token for the logged-in user. */
export async function fetchChatCredentials(
token: string,
options: { refresh?: boolean } = {},
): Promise<ChatCredentials> {
const res = await fetch("/api/chat/token", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
// `refresh` forces the backend to drop its cached token instead of handing
// back the one Synapse just rejected.
body: JSON.stringify({ refresh: options.refresh === true }),
});
if (res.status === 409) {
throw new ChatSetupPendingError("matrix_not_provisioned");
}
if (!res.ok) {
throw new Error(await readError(res));
}
return (await res.json()) as ChatCredentials;
}
/** Resolves the Matrix room id of a team. */
export async function fetchTeamRoomId(token: string, teamId: string): Promise<string> {
const res = await fetch(`/api/chat/room?teamId=${encodeURIComponent(teamId)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 409) {
throw new ChatSetupPendingError("room_not_provisioned");
}
if (!res.ok) {
throw new Error(await readError(res));
}
const body = (await res.json()) as { matrixRoomId: string };
return body.matrixRoomId;
}
/**
* Creates a Matrix client that talks to Synapse directly.
* Chat traffic never passes through the TalkTime backend.
*/
export function createMatrixClient(creds: ChatCredentials): MatrixClient {
return createClient({
baseUrl: creds.homeserverUrl,
accessToken: creds.accessToken,
userId: creds.userId,
});
}
export function formatDayLabel(date: Date): string {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString();
if (sameDay(date, today)) return "Heute";
if (sameDay(date, yesterday)) return "Gestern";
return date.toLocaleDateString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function formatTime(date: Date): string {
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
+36 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { Suspense, lazy, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -13,6 +13,10 @@ import {
import { useAuth } from "@/lib/auth-context";
import { useOrganization } from "@/lib/organization-context";
import { useApiList } from "@/hooks/use-api-list";
// Lazy: pulls in matrix-js-sdk only once a team is actually opened.
const TeamDetail = lazy(() =>
import("@/components/chat/TeamDetail").then((m) => ({ default: m.TeamDetail })),
);
interface Department {
id: string;
@@ -36,6 +40,7 @@ export function TeamsPage() {
const [departmentId, setDepartmentId] = useState<string>("");
const [seasonId, setSeasonId] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const orgQuery = activeOrganizationId ? `organizationId=${activeOrganizationId}` : null;
const { data: departments } = useApiList<Department>(
@@ -79,6 +84,8 @@ export function TeamsPage() {
}
}
const selectedTeam = teams.find((t) => t.id === selectedTeamId) ?? null;
const departmentName = (id: string) => departments.find((d) => d.id === id)?.name ?? "?";
const seasonName = (id: string) => seasons.find((s) => s.id === id)?.name ?? "?";
@@ -140,7 +147,22 @@ export function TeamsPage() {
<p className="text-sm text-muted-foreground">Keine Teams</p>
) : (
teams.map((team) => (
<Card key={team.id}>
<Card
key={team.id}
role="button"
tabIndex={0}
aria-pressed={selectedTeamId === team.id}
onClick={() => setSelectedTeamId(team.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedTeamId(team.id);
}
}}
className={`cursor-pointer transition-colors hover:bg-accent ${
selectedTeamId === team.id ? "ring-2 ring-ring" : ""
}`}
>
<CardContent className="pt-6 space-y-1">
<p className="font-medium">{team.name}</p>
<p className="text-xs text-muted-foreground">
@@ -151,6 +173,18 @@ export function TeamsPage() {
))
)}
</div>
{selectedTeam && (
<Suspense fallback={<p className="text-sm text-muted-foreground">Lädt…</p>}>
<TeamDetail
teamId={selectedTeam.id}
teamName={selectedTeam.name}
departmentName={departmentName(selectedTeam.departmentId)}
seasonName={seasonName(selectedTeam.seasonId)}
onClose={() => setSelectedTeamId(null)}
/>
</Suspense>
)}
</main>
);
}