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:
1 parent
8b6114c3e8
commit
7fa658b4ff
17 files changed
+1205
-7
No files matched your search
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { db } from "../db/index.js";
|
||||
import { members, memberStatusEnum } from "../db/schema.js";
|
||||
import { requesterIsOrganizationMember, requesterIsOrganizationAdmin } from "../lib/authz.js";
|
||||
import { deactivateUser } from "../services/synapse.js";
|
||||
import { invalidateMatrixAccessToken } from "../services/matrix-token-cache.js";
|
||||
import { logAudit } from "../lib/audit.js";
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
@@ -88,6 +89,8 @@ export async function memberRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
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 {
|
||||
await deactivateUser(member.matrixUserId, false);
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { organizations, members, organizationRoles, users } from "../db/schema.js";
|
||||
import { logAudit } from "../lib/audit.js";
|
||||
import { enqueueSyncJob } from "../services/sync-worker.js";
|
||||
|
||||
const createOrganizationSchema = z.object({
|
||||
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 firstName = requester.email.split("@")[0];
|
||||
const lastName = "";
|
||||
|
||||
const [member] = await db
|
||||
.insert(members)
|
||||
.values({
|
||||
organizationId: organization.id,
|
||||
userId: request.user.sub,
|
||||
firstName: requester.email.split("@")[0],
|
||||
lastName: "",
|
||||
firstName,
|
||||
lastName,
|
||||
email: requester.email,
|
||||
})
|
||||
.returning({ id: members.id });
|
||||
@@ -66,6 +70,15 @@ export async function organizationRoutes(app: FastifyInstance) {
|
||||
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({
|
||||
organizationId: organization.id,
|
||||
actorUserId: request.user.sub,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { departmentRoutes } from "./routes/departments.js";
|
||||
import { seasonRoutes } from "./routes/seasons.js";
|
||||
import { roleRoutes } from "./routes/roles.js";
|
||||
import { auditRoutes } from "./routes/audit.js";
|
||||
import { chatRoutes } from "./routes/chat.js";
|
||||
import { startSyncWorker, startReconciliationWorker } from "./services/sync-worker.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
@@ -43,6 +44,7 @@ await app.register(departmentRoutes);
|
||||
await app.register(seasonRoutes);
|
||||
await app.register(roleRoutes);
|
||||
await app.register(auditRoutes);
|
||||
await app.register(chatRoutes);
|
||||
|
||||
const port = Number(process.env.PORT ?? 4000);
|
||||
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);
|
||||
}
|
||||
@@ -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 {
|
||||
roomId: string;
|
||||
}
|
||||
@@ -118,6 +144,14 @@ export async function createRoom(params: {
|
||||
name: params.name,
|
||||
room_alias_name: params.alias,
|
||||
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 };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PROJ-11: Chat (Matrix Client-Server API)
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** In Progress
|
||||
**Created:** 2026-09-11
|
||||
|
||||
## Summary
|
||||
@@ -45,6 +45,33 @@ Eigene Chat-Oberfläche für Team-Räume, die bereits über [[PROJ-9]] (Synapse-
|
||||
## 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.
|
||||
|
||||
## 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 geminteten 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)
|
||||
|
||||
### 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/OrganizationSwitcher.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
|
||||
- [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] 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
|
||||
- Dedizierte Tenant-Isolation-Sicherheitstests (PRD-Erfolgsmetrik: 0 Cross-Club-Vorfälle) noch nicht als QA-Suite gebaut
|
||||
|
||||
Generated
+160
-2
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user