diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts new file mode 100644 index 0000000..e4f6009 --- /dev/null +++ b/backend/src/routes/chat.ts @@ -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 }); + }); +} diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts index 0468ed1..2a8c0c8 100644 --- a/backend/src/routes/members.ts +++ b/backend/src/routes/members.ts @@ -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) { diff --git a/backend/src/routes/organizations.ts b/backend/src/routes/organizations.ts index 61cea27..74077ee 100644 --- a/backend/src/routes/organizations.ts +++ b/backend/src/routes/organizations.ts @@ -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, diff --git a/backend/src/server.ts b/backend/src/server.ts index a2664f8..1e5ffd2 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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) => { diff --git a/backend/src/services/matrix-token-cache.ts b/backend/src/services/matrix-token-cache.ts new file mode 100644 index 0000000..a46e3b8 --- /dev/null +++ b/backend/src/services/matrix-token-cache.ts @@ -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(); +/** Dedupes concurrent mints for the same user (chat tab opened twice at once). */ +const inFlight = new Map>(); + +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 { + 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); +} diff --git a/backend/src/services/synapse.ts b/backend/src/services/synapse.ts index f0cc83d..e8c1246 100644 --- a/backend/src/services/synapse.ts +++ b/backend/src/services/synapse.ts @@ -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 }; diff --git a/features/PROJ-11-chat.md b/features/PROJ-11-chat.md index 79e703e..858d544 100644 --- a/features/PROJ-11-chat.md +++ b/features/PROJ-11-chat.md @@ -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 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) ### A) Komponenten-Struktur diff --git a/features/PROJ-2-verein-und-vereinsadministrator.md b/features/PROJ-2-verein-und-vereinsadministrator.md index 30127e5..7c69913 100644 --- a/features/PROJ-2-verein-und-vereinsadministrator.md +++ b/features/PROJ-2-verein-und-vereinsadministrator.md @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index daf90e2..ec8b995 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 27a9b67..67b8567 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/chat/MessageComposer.tsx b/frontend/src/components/chat/MessageComposer.tsx new file mode 100644 index 0000000..bfcd0b5 --- /dev/null +++ b/frontend/src/components/chat/MessageComposer.tsx @@ -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; +} + +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) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void submit(); + } + } + + function onSubmit(event: FormEvent) { + event.preventDefault(); + void submit(); + } + + return ( +
+