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

+147
View File
@@ -0,0 +1,147 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { chatRooms, members, teamMembers, teams } from "../db/schema.js";
import { publicHomeserverUrl } from "../services/synapse.js";
import {
getMatrixAccessToken,
invalidateMatrixAccessToken,
} from "../services/matrix-token-cache.js";
import { logAudit } from "../lib/audit.js";
/**
* PROJ-11: minimal backend surface for the chat feature.
* The browser talks to Synapse directly for /sync, sending and read receipts —
* these endpoints only hand out a short-lived per-member Matrix access token and
* resolve the Matrix room id of a team.
*/
const roomQuerySchema = z.object({
teamId: z.string().uuid(),
});
/**
* `refresh: true` is sent by the frontend's single automatic retry after Synapse
* rejected a token — otherwise it would just get the same cached token back.
*/
const tokenBodySchema = z.object({
refresh: z.boolean().optional(),
});
export async function chatRoutes(app: FastifyInstance) {
app.post(
"/api/chat/token",
{
preHandler: app.authenticate,
config: { rateLimit: { max: 10, timeWindow: "1 minute" } },
},
async (request, reply) => {
const parsedBody = tokenBodySchema.safeParse(request.body ?? {});
if (!parsedBody.success) {
return reply.code(400).send({ error: parsedBody.error.issues[0].message });
}
const rows = await db
.select({
id: members.id,
organizationId: members.organizationId,
matrixUserId: members.matrixUserId,
})
.from(members)
.where(and(eq(members.userId, request.user.sub), eq(members.status, "active")));
const provisioned = rows.find((row) => row.matrixUserId);
if (rows.length === 0) {
return reply.code(403).send({ error: "Forbidden" });
}
if (!provisioned?.matrixUserId) {
// Provisioning has not run yet — the UI shows "Chat wird eingerichtet" and polls.
return reply.code(409).send({ error: "matrix_not_provisioned" });
}
const matrixUserId = provisioned.matrixUserId;
if (parsedBody.data.refresh) {
invalidateMatrixAccessToken(matrixUserId);
}
try {
const { accessToken, expiresInMs, minted } = await getMatrixAccessToken(matrixUserId);
if (minted) {
// Only newly minted tokens are audited — cache hits would flood the log.
// The token itself is never written to the audit log.
await logAudit({
organizationId: provisioned.organizationId,
actorUserId: request.user.sub,
action: "chat.token_issued",
targetType: "member",
targetId: provisioned.id,
metadata: {
matrixUserId,
expiresInMs,
refresh: parsedBody.data.refresh === true,
},
});
}
return reply.send({
homeserverUrl: publicHomeserverUrl(),
userId: matrixUserId,
accessToken,
expiresInMs,
});
} catch (err) {
request.log.error({ err }, "[chat] failed to mint matrix access token");
return reply.code(502).send({ error: "synapse_unavailable" });
}
},
);
app.get("/api/chat/room", { preHandler: app.authenticate }, async (request, reply) => {
const parsed = roomQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0].message });
}
const { teamId } = parsed.data;
const [team] = await db
.select({ organizationId: teams.organizationId })
.from(teams)
.where(eq(teams.id, teamId))
.limit(1);
if (!team) {
return reply.code(404).send({ error: "Team not found" });
}
// Only members of that very team may read its room id.
const [membership] = await db
.select({ id: teamMembers.id })
.from(teamMembers)
.innerJoin(members, eq(members.id, teamMembers.memberId))
.where(
and(
eq(teamMembers.teamId, teamId),
eq(members.userId, request.user.sub),
eq(members.status, "active"),
),
)
.limit(1);
if (!membership) {
return reply.code(403).send({ error: "Forbidden" });
}
const [room] = await db
.select({ matrixRoomId: chatRooms.matrixRoomId })
.from(chatRooms)
.where(and(eq(chatRooms.teamId, teamId), eq(chatRooms.scope, "team")))
.limit(1);
if (!room) {
// Room creation job has not run yet — UI shows "Raum wird eingerichtet".
return reply.code(409).send({ error: "room_not_provisioned" });
}
return reply.send({ matrixRoomId: room.matrixRoomId });
});
}
+3
View File
@@ -5,6 +5,7 @@ import { db } from "../db/index.js";
import { members, memberStatusEnum } from "../db/schema.js"; import { members, memberStatusEnum } from "../db/schema.js";
import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../lib/authz.js"; import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../lib/authz.js";
import { deactivateUser } from "../services/synapse.js"; import { deactivateUser } from "../services/synapse.js";
import { invalidateMatrixAccessToken } from "../services/matrix-token-cache.js";
import { logAudit } from "../lib/audit.js"; import { logAudit } from "../lib/audit.js";
const listQuerySchema = z.object({ const listQuerySchema = z.object({
@@ -88,6 +89,8 @@ export async function memberRoutes(app: FastifyInstance) {
}); });
if ((status === "suspended" || status === "left") && member.matrixUserId) { if ((status === "suspended" || status === "left") && member.matrixUserId) {
// Drop any cached chat token so a suspended member cannot keep using it (PROJ-11).
invalidateMatrixAccessToken(member.matrixUserId);
try { try {
await deactivateUser(member.matrixUserId, false); await deactivateUser(member.matrixUserId, false);
} catch (err) { } catch (err) {
+15 -2
View File
@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
import { db } from "../db/index.js"; import { db } from "../db/index.js";
import { organizations, members, organizationRoles, users } from "../db/schema.js"; import { organizations, members, organizationRoles, users } from "../db/schema.js";
import { logAudit } from "../lib/audit.js"; import { logAudit } from "../lib/audit.js";
import { enqueueSyncJob } from "../services/sync-worker.js";
const createOrganizationSchema = z.object({ const createOrganizationSchema = z.object({
name: z.string().min(2), name: z.string().min(2),
@@ -49,13 +50,16 @@ export async function organizationRoutes(app: FastifyInstance) {
const [organization] = await db.insert(organizations).values({ name, slug }).returning(); const [organization] = await db.insert(organizations).values({ name, slug }).returning();
const firstName = requester.email.split("@")[0];
const lastName = "";
const [member] = await db const [member] = await db
.insert(members) .insert(members)
.values({ .values({
organizationId: organization.id, organizationId: organization.id,
userId: request.user.sub, userId: request.user.sub,
firstName: requester.email.split("@")[0], firstName,
lastName: "", lastName,
email: requester.email, email: requester.email,
}) })
.returning({ id: members.id }); .returning({ id: members.id });
@@ -66,6 +70,15 @@ export async function organizationRoutes(app: FastifyInstance) {
role: "ORG_ADMIN", role: "ORG_ADMIN",
}); });
// The org creator only ever goes through this route, never the invitation-accept
// path that normally triggers provisioning — without this they'd never get a
// Matrix account and PROJ-11 chat would poll "wird eingerichtet" forever.
await enqueueSyncJob(organization.id, "provision_matrix_user", {
memberId: member.id,
firstName,
lastName,
});
await logAudit({ await logAudit({
organizationId: organization.id, organizationId: organization.id,
actorUserId: request.user.sub, actorUserId: request.user.sub,
+2
View File
@@ -15,6 +15,7 @@ import { departmentRoutes } from "./routes/departments.js";
import { seasonRoutes } from "./routes/seasons.js"; import { seasonRoutes } from "./routes/seasons.js";
import { roleRoutes } from "./routes/roles.js"; import { roleRoutes } from "./routes/roles.js";
import { auditRoutes } from "./routes/audit.js"; import { auditRoutes } from "./routes/audit.js";
import { chatRoutes } from "./routes/chat.js";
import { startSyncWorker, startReconciliationWorker } from "./services/sync-worker.js"; import { startSyncWorker, startReconciliationWorker } from "./services/sync-worker.js";
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
@@ -43,6 +44,7 @@ await app.register(departmentRoutes);
await app.register(seasonRoutes); await app.register(seasonRoutes);
await app.register(roleRoutes); await app.register(roleRoutes);
await app.register(auditRoutes); await app.register(auditRoutes);
await app.register(chatRoutes);
const port = Number(process.env.PORT ?? 4000); const port = Number(process.env.PORT ?? 4000);
app.listen({ port, host: "0.0.0.0" }).catch((err) => { app.listen({ port, host: "0.0.0.0" }).catch((err) => {
@@ -0,0 +1,87 @@
import { loginAsUser } from "./synapse.js";
/**
* PROJ-11: server-side cache for per-member Matrix access tokens.
*
* Without it every chat tab open mints a fresh token via the Synapse admin API,
* which both hammers Synapse and leaves a growing pile of live tokens behind.
* Tokens stay in process memory only — never in the TalkTime database, never in logs.
*/
export const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
/** Hand out a cached token only while it is still valid for at least this long. */
const REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes
const MAX_ENTRIES = 5_000;
interface CacheEntry {
accessToken: string;
/** Absolute epoch ms at which Synapse invalidates this token. */
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
/** Dedupes concurrent mints for the same user (chat tab opened twice at once). */
const inFlight = new Map<string, Promise<CacheEntry>>();
function prune(now: number): void {
for (const [userId, entry] of cache) {
if (entry.expiresAt <= now) {
cache.delete(userId);
}
}
// Hard cap so a large org cannot grow the map without bound; oldest insertions go first.
while (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next();
if (oldest.done) break;
cache.delete(oldest.value);
}
}
export interface CachedMatrixToken {
accessToken: string;
/** Remaining lifetime in ms — what the browser uses to schedule a refresh. */
expiresInMs: number;
/** False when the token was served from cache (used for audit-log noise reduction). */
minted: boolean;
}
export async function getMatrixAccessToken(matrixUserId: string): Promise<CachedMatrixToken> {
const now = Date.now();
prune(now);
const cached = cache.get(matrixUserId);
if (cached && cached.expiresAt - now > REFRESH_MARGIN_MS) {
return {
accessToken: cached.accessToken,
expiresInMs: cached.expiresAt - now,
minted: false,
};
}
let pending = inFlight.get(matrixUserId);
if (!pending) {
const expiresAt = now + TOKEN_TTL_MS;
pending = loginAsUser(matrixUserId, expiresAt)
.then(({ accessToken }) => {
const entry: CacheEntry = { accessToken, expiresAt };
cache.set(matrixUserId, entry);
return entry;
})
.finally(() => {
inFlight.delete(matrixUserId);
});
inFlight.set(matrixUserId, pending);
}
const entry = await pending;
return {
accessToken: entry.accessToken,
expiresInMs: Math.max(0, entry.expiresAt - Date.now()),
minted: true,
};
}
/** Drop a cached token, e.g. after the member was deactivated or the token was rejected. */
export function invalidateMatrixAccessToken(matrixUserId: string): void {
cache.delete(matrixUserId);
}
+34
View File
@@ -104,6 +104,32 @@ export async function reactivateUser(userId: string, newPassword: string): Promi
}); });
} }
/**
* Public base URL browsers use to reach Synapse directly (PROJ-11).
* Falls back to the internal URL for single-host/dev setups.
*/
export function publicHomeserverUrl(): string {
return process.env.SYNAPSE_PUBLIC_BASE_URL ?? baseUrl;
}
/**
* Mints a short-lived access token for a single member via the Synapse admin API.
* The admin token itself never leaves the backend.
*/
export async function loginAsUser(
userId: string,
validUntilMs: number,
): Promise<{ accessToken: string }> {
const result = await synapseFetch<{ access_token: string }>(
`/_synapse/admin/v1/users/${encodeURIComponent(userId)}/login`,
{
method: "POST",
body: JSON.stringify({ valid_until_ms: validUntilMs }),
},
);
return { accessToken: result.access_token };
}
export interface CreateRoomResult { export interface CreateRoomResult {
roomId: string; roomId: string;
} }
@@ -118,6 +144,14 @@ export async function createRoom(params: {
name: params.name, name: params.name,
room_alias_name: params.alias, room_alias_name: params.alias,
preset: "private_chat", preset: "private_chat",
// PROJ-11: new members must see the full backlog when they join the team room.
initial_state: [
{
type: "m.room.history_visibility",
state_key: "",
content: { history_visibility: "shared" },
},
],
}), }),
}); });
return { roomId: result.room_id }; return { roomId: result.room_id };
+28 -1
View File
@@ -1,6 +1,6 @@
# PROJ-11: Chat (Matrix Client-Server API) # PROJ-11: Chat (Matrix Client-Server API)
**Status:** Planned **Status:** In Progress
**Created:** 2026-09-11 **Created:** 2026-09-11
## Summary ## Summary
@@ -45,6 +45,33 @@ Eigene Chat-Oberfläche für Team-Räume, die bereits über [[PROJ-9]] (Synapse-
## Notes ## Notes
Matrix-Access-Token-Handling ist sicherheitsrelevant (Token-Mapping TalkTime-User → Matrix-User) — Architektur-Skill sollte hier besonders auf Token-Lifetime, Caching-Ort (nicht im Frontend!) und Re-Auth-Flow eingehen. `backend-security-coder`/`frontend-security-coder`-Subagenten bei der Umsetzung hinzuziehen. Matrix-Access-Token-Handling ist sicherheitsrelevant (Token-Mapping TalkTime-User → Matrix-User) — Architektur-Skill sollte hier besonders auf Token-Lifetime, Caching-Ort (nicht im Frontend!) und Re-Auth-Flow eingehen. `backend-security-coder`/`frontend-security-coder`-Subagenten bei der Umsetzung hinzuziehen.
## Implementation Notes (Frontend-Pass, 2026-09-11)
Gebaut:
- `frontend/src/lib/matrix.ts` — Token-/Raum-Abruf beim TalkTime-Backend, `createMatrixClient`, Datums-/Zeit-Formatierung, `ChatSetupPendingError`
- `frontend/src/hooks/use-team-chat.ts` — matrix-js-sdk-Client pro geöffnetem Team, `/sync` direkt gegen Synapse, Timeline/Receipt-Listener, Backfill via `paginateEventTimeline`, Read Receipts, ein automatischer Retry mit frischem Token beim Senden
- `frontend/src/components/chat/` — `TeamDetail.tsx` (Tabs Übersicht/Chat + Ungelesen-Badge am Tab), `TeamChat.tsx` (Verbindungs-/Setup-Zustände), `MessageList.tsx` (Tages-Gruppen, „gelesen von: …", Ältere-Nachrichten-Button), `MessageComposer.tsx`
- `frontend/src/pages/TeamsPage.tsx` — Team-Kacheln sind jetzt auswählbar, öffnen die Detailansicht; `TeamDetail` wird lazy geladen (matrix-js-sdk landet in eigenem Chunk, nicht im Initial-Bundle)
- Neu: `matrix-js-sdk` in `frontend/package.json` (frontend-only, wie im Tech Design vorgesehen)
Backend (bewusst minimal, nicht der volle Backend-Pass):
- `backend/src/routes/chat.ts` — `POST /api/chat/token` (mintet 1h-Token pro Mitglied, 409 `matrix_not_provisioned` wenn `matrixUserId` fehlt) und `GET /api/chat/room?teamId=` (409 `room_not_provisioned`); registriert in `server.ts`
- `backend/src/services/synapse.ts` — `loginAsUser()` (Admin-API `/_synapse/admin/v1/users/{id}/login`) und `publicHomeserverUrl()`
Offen für den `/backend`-Pass:
- Ungelesen-Zähler auf den Team-Kacheln (außerhalb des geöffneten Chats) — bräuchte einen app-weiten Matrix-Client statt einen pro geöffnetem Team; aktuell nur Badge am Chat-Tab
- Deployment-Voraussetzung unverändert offen: Synapse muss vom Browser aus per TLS erreichbar sein
## Implementation Notes (Backend-Pass, 2026-09-11)
- `backend/src/services/matrix-token-cache.ts` (neu) — In-Memory-Cache für die pro Mitglied gemin­teten Matrix-Access-Tokens (TTL 1 h, Wiederverwendung bis 5 min vor Ablauf, Dedupe gleichzeitiger Mints, harte Obergrenze 5.000 Einträge). Tokens liegen ausschließlich im Prozessspeicher, nie in der DB, nie im Log.
- `backend/src/routes/chat.ts` — `POST /api/chat/token` nutzt den Cache; optionaler Body `{ refresh: true }` (Zod-validiert) invalidiert den Cache-Eintrag, damit der automatische Retry des Frontends nicht denselben abgelehnten Token zurückbekommt. Neu gemintete Tokens erzeugen einen Audit-Log-Eintrag `chat.token_issued` (Metadaten: matrixUserId, expiresInMs, refresh) — Cache-Treffer werden bewusst nicht geloggt, und der Token selbst steht nie im Audit-Log.
- `backend/src/routes/members.ts` — bei Statuswechsel auf `suspended`/`left` wird der gecachte Chat-Token sofort invalidiert.
- `backend/src/services/synapse.ts` — `createRoom()` setzt jetzt `initial_state` mit `m.room.history_visibility: "shared"`, damit neue Raum-Mitglieder die volle bisherige Historie sehen (Akzeptanzkriterium). Gilt für neu angelegte Räume; bereits bestehende Testräume müssten einmalig nachgezogen werden.
- `frontend/src/lib/matrix.ts` / `use-team-chat.ts` — `fetchChatCredentials(token, { refresh })`, Retry beim Senden fordert explizit einen frischen Token an.
Weiterhin offen (Berechtigung fehlte): `SYNAPSE_PUBLIC_BASE_URL` ist in `backend/.env.example` zu ergänzen (Fallback im Code ist `SYNAPSE_BASE_URL`) — die Datei ist für den Agenten nicht schreibbar, bitte manuell eintragen.
## Tech Design (Solution Architect) ## Tech Design (Solution Architect)
### A) Komponenten-Struktur ### A) Komponenten-Struktur
@@ -11,11 +11,14 @@ Anlage einer Vereins-Organisation (Tenant) samt Vereinsadministrator (CLUB_ADMIN
- `frontend/src/components/dashboard/CreateOrganizationForm.tsx` - `frontend/src/components/dashboard/CreateOrganizationForm.tsx`
- `frontend/src/components/dashboard/OrganizationSwitcher.tsx` - `frontend/src/components/dashboard/OrganizationSwitcher.tsx`
- `frontend/src/lib/organization-context.tsx` - `frontend/src/lib/organization-context.tsx`
- **Nachtrag 2026-09-11 (Bugfix während PROJ-11-Test gefunden):** `POST /api/organizations` provisioniert jetzt auch für den Org-Ersteller einen Matrix-Account (`enqueueSyncJob(..., "provision_matrix_user", ...)`). Vorher lief Matrix-Provisioning nur über den Einladungs-Annahme-Flow ([[PROJ-7]]) — der Ersteller/erste CLUB_ADMIN bekam nie einen Matrix-Account und hätte in [[PROJ-11]] (Chat) dauerhaft nur "wird eingerichtet" gesehen.
## Acceptance Criteria ## Acceptance Criteria
- [x] Nutzer kann neuen Verein anlegen und wird automatisch CLUB_ADMIN - [x] Nutzer kann neuen Verein anlegen und wird automatisch CLUB_ADMIN
- [x] Multi-Tenant: ein Nutzer kann Mitglied mehrerer Vereine sein, Frontend erlaubt Wechsel - [x] Multi-Tenant: ein Nutzer kann Mitglied mehrerer Vereine sein, Frontend erlaubt Wechsel
- [x] Tenant-Isolation in Route-Handlern geprüft (kein DB-RLS-Backstop, siehe `.claude/rules/security.md`) - [x] Tenant-Isolation in Route-Handlern geprüft (kein DB-RLS-Backstop, siehe `.claude/rules/security.md`)
- [x] Org-Ersteller bekommt automatisch einen Matrix-Account provisioniert (nicht nur eingeladene Mitglieder)
- [x] `GET /api/organizations/mine` listet nur Organisationen mit `status = "active"` (siehe [[PROJ-3]]-Nachtrag zur Zugriffssperre bei Austritt)
## Open Items ## Open Items
- Dedizierte Tenant-Isolation-Sicherheitstests (PRD-Erfolgsmetrik: 0 Cross-Club-Vorfälle) noch nicht als QA-Suite gebaut - Dedizierte Tenant-Isolation-Sicherheitstests (PRD-Erfolgsmetrik: 0 Cross-Club-Vorfälle) noch nicht als QA-Suite gebaut
+160 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "ai-coding-starter-kit-frontend", "name": "talktime-frontend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ai-coding-starter-kit-frontend", "name": "talktime-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
@@ -33,6 +33,7 @@
"clsx": "^2.1.0", "clsx": "^2.1.0",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"matrix-js-sdk": "^42.3.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -302,6 +303,12 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1224,6 +1231,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "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, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2927,6 +2949,12 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -3034,6 +3062,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.11.21", "version": "2.11.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", "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": "^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": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3288,6 +3331,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -3649,6 +3705,15 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3988,6 +4053,18 @@
"node": ">=0.10.0" "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": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -4160,6 +4237,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "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" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4361,6 +4489,21 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "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==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "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": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -5245,6 +5397,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/update-browserslist-db": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", "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", "clsx": "^2.1.0",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"matrix-js-sdk": "^42.3.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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 { Link } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -13,6 +13,10 @@ import {
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { useOrganization } from "@/lib/organization-context"; import { useOrganization } from "@/lib/organization-context";
import { useApiList } from "@/hooks/use-api-list"; 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 { interface Department {
id: string; id: string;
@@ -36,6 +40,7 @@ export function TeamsPage() {
const [departmentId, setDepartmentId] = useState<string>(""); const [departmentId, setDepartmentId] = useState<string>("");
const [seasonId, setSeasonId] = useState<string>(""); const [seasonId, setSeasonId] = useState<string>("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const orgQuery = activeOrganizationId ? `organizationId=${activeOrganizationId}` : null; const orgQuery = activeOrganizationId ? `organizationId=${activeOrganizationId}` : null;
const { data: departments } = useApiList<Department>( 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 departmentName = (id: string) => departments.find((d) => d.id === id)?.name ?? "?";
const seasonName = (id: string) => seasons.find((s) => s.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> <p className="text-sm text-muted-foreground">Keine Teams</p>
) : ( ) : (
teams.map((team) => ( 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"> <CardContent className="pt-6 space-y-1">
<p className="font-medium">{team.name}</p> <p className="font-medium">{team.name}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -151,6 +173,18 @@ export function TeamsPage() {
)) ))
)} )}
</div> </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> </main>
); );
} }