From 0ab7cf2ab86730341d161515c5fe7d5d3c1b62ec Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 25 Aug 2026 02:15:44 +0800 Subject: [PATCH] feat: improve collaboration rooms (#1338) --- README.md | 6 + docker/compose-dev.yml | 2 + docker/docker-compose.yml | 5 + docs/readme/README-CN.md | 5 + package-lock.json | 100 +++++- package.json | 1 + src/backend/hosts/collab/room-hub.ts | 52 ++- src/backend/hosts/collab/routes.ts | 188 +++++++++-- src/backend/hosts/collab/runtime-store.ts | 286 ++++++++++++++++ src/backend/hosts/collab/stage-control.ts | 16 +- .../tests/hosts/collab/room-hub.test.ts | 50 ++- src/backend/tests/hosts/collab/routes.test.ts | 81 ++++- .../tests/hosts/collab/runtime-store.test.ts | 39 +++ src/ui/api/collab-api.ts | 40 ++- .../features/collab/CollabMembersSidebar.tsx | 287 ++++++++++++++++ src/ui/features/collab/CollabRoomTab.tsx | 312 ++++++++---------- src/ui/locales/en.json | 12 + 17 files changed, 1260 insertions(+), 222 deletions(-) create mode 100644 src/backend/hosts/collab/runtime-store.ts create mode 100644 src/backend/tests/hosts/collab/runtime-store.test.ts create mode 100644 src/ui/features/collab/CollabMembersSidebar.tsx diff --git a/README.md b/README.md index b7a8e592..a854608a 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,12 @@ networks: driver: bridge ``` +For multiple Termix backend instances, set the same `REDIS_URL` and optional +`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room +presence, control requests, controller state, and events; keep WebSocket session +affinity enabled because live SSH and remote desktop transports remain attached +to the backend instance that opened them. A single instance needs no Redis. + ### Command Line Interface Termix also has a CLI, so you can manage your servers from a terminal and use Termix in your own scripts. diff --git a/docker/compose-dev.yml b/docker/compose-dev.yml index 4165ad97..7ad7a69b 100644 --- a/docker/compose-dev.yml +++ b/docker/compose-dev.yml @@ -16,6 +16,8 @@ services: GUACD_TUNNEL_HOST: "termix-dev" GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" GUACD_DRIVE_PATH: "/termix-data/rdp-drive" + # REDIS_URL: "redis://redis:6379" + # TERMIX_REDIS_PREFIX: "termix:collab" depends_on: - guacd-dev networks: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index fda099a7..24ee6e95 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,6 +15,11 @@ services: # Where guacd stores files for RDP drive redirection (one folder per # user is created underneath). Must be writable by guacd's user. GUACD_DRIVE_PATH: "/termix-data/rdp-drive" + # Multi-instance deployments should point every backend at the same + # Redis. Without it, collaboration rooms keep their single-instance + # in-memory fallback. + # REDIS_URL: "redis://redis:6379" + # TERMIX_REDIS_PREFIX: "termix:collab" # Hardened deployments can require keys from environment variables or # Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE, # ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE. diff --git a/docs/readme/README-CN.md b/docs/readme/README-CN.md index b1893430..e54d025a 100644 --- a/docs/readme/README-CN.md +++ b/docs/readme/README-CN.md @@ -350,6 +350,11 @@ networks: driver: bridge ``` +部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置 +`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件; +实时 SSH 与远程桌面传输仍依附于创建连接的后端实例,因此负载均衡器需要保持 +WebSocket 会话亲和性。单实例部署无需 Redis。 + ### 命令行工具 Termix 还提供命令行工具,你可以在终端里管理服务器,也可以把 Termix 用在自己的脚本中。 diff --git a/package-lock.json b/package-lock.json index 0aa1f668..b9873c84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "termix", "version": "2.7.1", - "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.116.0", "@simplewebauthn/browser": "^13.3.0", @@ -39,6 +38,7 @@ "node-pty": "^1.1.0", "pg": "^8.22.0", "qrcode": "^1.5.4", + "redis": "^6.2.1", "serialport": "^13.0.0", "sharp": "^0.35.3", "socks": "^2.8.7", @@ -5647,6 +5647,78 @@ "dev": true, "license": "MIT" }, + "node_modules/@redis/bloom": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.2.1.tgz", + "integrity": "sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.2.1" + } + }, + "node_modules/@redis/client": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.2.1.tgz", + "integrity": "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.2.1.tgz", + "integrity": "sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.2.1" + } + }, + "node_modules/@redis/search": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.2.1.tgz", + "integrity": "sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.2.1" + } + }, + "node_modules/@redis/time-series": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.2.1.tgz", + "integrity": "sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.2.1" + } + }, "node_modules/@replit/codemirror-lang-nix": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-nix/-/codemirror-lang-nix-6.0.1.tgz", @@ -8658,6 +8730,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -9180,6 +9253,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cmdk": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", @@ -15882,6 +15964,22 @@ "node": ">=8" } }, + "node_modules/redis": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-6.2.1.tgz", + "integrity": "sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "6.2.1", + "@redis/client": "6.2.1", + "@redis/json": "6.2.1", + "@redis/search": "6.2.1", + "@redis/time-series": "6.2.1" + }, + "engines": { + "node": ">= 20.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", diff --git a/package.json b/package.json index 2c8de073..cf7742a6 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "node-pty": "^1.1.0", "pg": "^8.22.0", "qrcode": "^1.5.4", + "redis": "^6.2.1", "serialport": "^13.0.0", "sharp": "^0.35.3", "socks": "^2.8.7", diff --git a/src/backend/hosts/collab/room-hub.ts b/src/backend/hosts/collab/room-hub.ts index fb11eecf..c75d74d6 100644 --- a/src/backend/hosts/collab/room-hub.ts +++ b/src/backend/hosts/collab/room-hub.ts @@ -1,4 +1,7 @@ import type { WebSocket } from "ws"; +import { collabRuntimeStore } from "./runtime-store.js"; + +const PRESENCE_HEARTBEAT_MS = 15_000; export interface CollabRoomClient { ws: WebSocket; @@ -7,13 +10,28 @@ export interface CollabRoomClient { } /** - * In-memory fan-out for collab room events, mirroring the single-instance - * assumption TerminalSessionManager already makes. REST mutations broadcast - * through it; the terminal WS server feeds subscribe/unsubscribe. + * Local WebSocket fan-out plus optional Redis pub/sub for multi-instance room + * events and presence. The terminal WS server feeds subscribe/unsubscribe. */ class CollabRoomHub { private rooms = new Map>(); + constructor() { + collabRuntimeStore.onEvent((roomId, message) => { + if ( + "type" in message && + typeof message.type === "string" && + message.type.startsWith("collab_internal_") + ) { + return; + } + this.broadcastLocal(roomId, message); + }); + setInterval(() => { + for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId); + }, PRESENCE_HEARTBEAT_MS).unref(); + } + subscribe(roomId: string, client: CollabRoomClient): void { let clients = this.rooms.get(roomId); if (!clients) { @@ -24,7 +42,7 @@ class CollabRoomHub { if (existing.ws === client.ws) return; } clients.add(client); - this.broadcastOnline(roomId); + void this.refreshPresence(roomId); } /** Drops the socket from one room, or from every room when roomId is omitted. */ @@ -39,11 +57,25 @@ class CollabRoomHub { } } if (clients.size === 0) this.rooms.delete(id); - if (removed) this.broadcastOnline(id); + if (removed) void this.refreshPresence(id); } } broadcast(roomId: string, message: object): void { + this.broadcastLocal(roomId, message); + void collabRuntimeStore.publish(roomId, message); + } + + async onlineUsers( + roomId: string, + ): Promise> { + return collabRuntimeStore.onlineUsers( + roomId, + this.localOnlineUsers(roomId), + ); + } + + private broadcastLocal(roomId: string, message: object): void { const clients = this.rooms.get(roomId); if (!clients) return; const payload = JSON.stringify(message); @@ -57,7 +89,9 @@ class CollabRoomHub { } } - onlineUsers(roomId: string): Array<{ userId: string; username: string }> { + private localOnlineUsers( + roomId: string, + ): Array<{ userId: string; username: string }> { const clients = this.rooms.get(roomId); if (!clients) return []; const seen = new Map(); @@ -67,11 +101,13 @@ class CollabRoomHub { return Array.from(seen, ([userId, username]) => ({ userId, username })); } - private broadcastOnline(roomId: string): void { + private async refreshPresence(roomId: string): Promise { + const localUsers = this.localOnlineUsers(roomId); + await collabRuntimeStore.updatePresence(roomId, localUsers); this.broadcast(roomId, { type: "collab_online", roomId, - users: this.onlineUsers(roomId), + users: await this.onlineUsers(roomId), }); } } diff --git a/src/backend/hosts/collab/routes.ts b/src/backend/hosts/collab/routes.ts index d329b8b1..8e13b970 100644 --- a/src/backend/hosts/collab/routes.ts +++ b/src/backend/hosts/collab/routes.ts @@ -12,6 +12,10 @@ import { GuacamoleTokenService } from "../guacamole/token-service.js"; import { collabRoomHub } from "./room-hub.js"; import { getStageController, setStageController } from "./stage-control.js"; import { isCollabGuestRateLimited } from "./guest-rate-limit.js"; +import { + collabRuntimeStore, + type CollabControlRequest, +} from "./runtime-store.js"; import { sessionManager } from "../terminal/session-manager.js"; import { isLiveSession, @@ -29,9 +33,8 @@ import type { CollabRoomRecord } from "../../database/repositories/collab-room-r /* * Known limits, shared with session sharing v1: - * - Room events and stage control live in this process (room-hub, - * stage-control). With more than one backend instance, members connected - * to different instances do not see each other's events. + * - Redis synchronizes collaboration events and ephemeral state across + * instances, but live session transports still require WebSocket affinity. * - Guacamole stages stay read-only because guacamole-lite cannot revoke a * writable viewer without disconnecting the whole shared session. */ @@ -44,7 +47,6 @@ const STAGE_SHARE_EXPIRY_HOURS = 12; const MAX_INVITE_TARGETS = 200; const CONTROL_REQUEST_COOLDOWN_MS = 5000; const PROTOCOLS: LiveProtocol[] = ["ssh", "rdp", "vnc", "telnet"]; -const controlRequestTimes = new Map(); function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -74,6 +76,11 @@ async function revokeStageShare(room: CollabRoomRecord): Promise { sessionManager.disconnectShareParticipants(share.sessionId, share.id, { reason: "The collaboration stage ended", }); + void collabRuntimeStore.publish(room.id, { + type: "collab_internal_stage_revoked", + sessionId: share.sessionId, + shareId: share.id, + }); } } @@ -186,14 +193,20 @@ router.get( } const members = await createCurrentCollabRoomRepository().listMembers(roomId); + const mayReviewControlRequests = + access.isHost || access.room.presenterUserId === userId; + const controlRequests = await collabRuntimeStore.listRequests(roomId); res.json({ room: publicRoom(access.room), me: userId, isHost: access.isHost, members, - online: collabRoomHub.onlineUsers(roomId), + online: await collabRoomHub.onlineUsers(roomId), stage: stagePayload(access.room), - controllerUserId: getStageController(roomId), + controllerUserId: await getStageController(roomId), + controlRequests: mayReviewControlRequests + ? controlRequests + : controlRequests.filter((request) => request.userId === userId), }); } catch (error) { sshLogger.error("Failed to get collab room", error, { @@ -332,6 +345,7 @@ router.delete( const repository = createCurrentCollabRoomRepository(); await repository.removeMember(roomId, targetId); + await collabRuntimeStore.removeRequest(roomId, targetId); if (access.room.stageShareId && access.room.stageProtocol === "ssh") { const share = @@ -350,7 +364,7 @@ router.delete( } } - if (getStageController(roomId) === targetId) { + if ((await getStageController(roomId)) === targetId) { await applyStageControl(access.room, roomId, null); } @@ -455,7 +469,6 @@ router.post( }); await revokeStageShare(access.room); - setStageController(roomId, null); const repository = createCurrentCollabRoomRepository(); const replaced = await repository.replaceStage( roomId, @@ -471,6 +484,8 @@ router.post( await shareRepository.revokeAsAdmin(share.id); return res.status(409).json({ error: "The stage changed; try again" }); } + await setStageController(roomId, null); + await collabRuntimeStore.clearRequests(roomId); const stage = { presenterUserId: userId, @@ -534,7 +549,8 @@ router.post( } await revokeStageShare(access.room); - setStageController(roomId, null); + await setStageController(roomId, null); + await collabRuntimeStore.clearRequests(roomId); await createCurrentCollabRoomRepository().clearStage(roomId); collabRoomHub.broadcast(roomId, { type: "collab_stage_changed", @@ -595,7 +611,7 @@ router.get( if (!share || !isLiveSession(protocol, share.sessionId)) { // The presenter is gone (expired share or dead session): clear the // stale stage so the room stops pointing at it. - setStageController(roomId, null); + await setStageController(roomId, null); await createCurrentCollabRoomRepository().clearStage(roomId); collabRoomHub.broadcast(roomId, { type: "collab_stage_changed", @@ -605,7 +621,7 @@ router.get( return res.json({ stage: null }); } - const controllerUserId = getStageController(roomId); + const controllerUserId = await getStageController(roomId); const stage: Record = { ...stagePayload(room), sessionId: share.sessionId, @@ -632,7 +648,7 @@ async function applyStageControl( roomId: string, controllerUserId: string | null, ): Promise { - setStageController(roomId, controllerUserId); + await setStageController(roomId, controllerUserId); if (room.stageShareId && room.stageProtocol === "ssh") { try { const share = await createCurrentSessionShareRepository().findActiveById( @@ -656,6 +672,49 @@ async function applyStageControl( }); } +collabRuntimeStore.onEvent((roomId, message) => { + if (!("type" in message) || typeof message.type !== "string") return; + if (message.type === "collab_control_changed") { + const controllerUserId: string | null = + "controllerUserId" in message && + typeof message.controllerUserId === "string" + ? message.controllerUserId + : null; + void createCurrentCollabRoomRepository() + .findById(roomId) + .then((room) => { + if (!room?.stageShareId || room.stageProtocol !== "ssh") return; + return createCurrentSessionShareRepository() + .findActiveById(room.stageShareId) + .then((share) => { + if (share) { + sessionManager.setRoomShareControl( + share.sessionId, + share.id, + controllerUserId, + ); + } + }); + }) + .catch(() => {}); + } + if ( + message.type === "collab_internal_stage_revoked" && + "sessionId" in message && + typeof message.sessionId === "string" && + "shareId" in message && + typeof message.shareId === "string" + ) { + sessionManager.disconnectShareParticipants( + message.sessionId, + message.shareId, + { + reason: "The collaboration stage ended", + }, + ); + } +}); + /** * @openapi * /collab/rooms/{id}/control: @@ -694,7 +753,7 @@ router.post( } const releasingOwnControl = - targetId === null && getStageController(roomId) === userId; + targetId === null && (await getStageController(roomId)) === userId; const mayGrant = access.isHost || access.room.presenterUserId === userId; if (!mayGrant && !releasingOwnControl) { return res.status(403).json({ @@ -710,6 +769,13 @@ router.post( } await applyStageControl(access.room, roomId, targetId); + if (targetId) { + await collabRuntimeStore.removeRequest(roomId, targetId); + collabRoomHub.broadcast(roomId, { + type: "collab_control_requests_changed", + roomId, + }); + } const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, @@ -760,20 +826,26 @@ router.post( error: "Remote desktop stages are read-only", }); } - const requestKey = `${roomId}:${userId}`; - const now = Date.now(); + const existing = (await collabRuntimeStore.listRequests(roomId)).find( + (request) => request.userId === userId, + ); if ( - now - (controlRequestTimes.get(requestKey) ?? 0) < - CONTROL_REQUEST_COOLDOWN_MS + existing && + Date.now() - Date.parse(existing.requestedAt) < + CONTROL_REQUEST_COOLDOWN_MS ) { return res.status(429).json({ error: "Control was already requested" }); } - controlRequestTimes.set(requestKey, now); + const request: CollabControlRequest = { + userId, + username: await getAuditUsername(userId), + requestedAt: new Date().toISOString(), + }; + await collabRuntimeStore.upsertRequest(roomId, request); collabRoomHub.broadcast(roomId, { type: "collab_control_requested", roomId, - userId, - username: await getAuditUsername(userId), + ...request, }); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ @@ -787,7 +859,7 @@ router.post( userAgent, success: true, }); - res.json({ success: true }); + res.json({ request }); } catch (error) { sshLogger.error("Failed to request collab stage control", error, { operation: "collab_control_request_error", @@ -797,6 +869,77 @@ router.post( }, ); +router.get( + "/rooms/:id/control/requests", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId!; + const roomId = String(req.params.id); + try { + const access = await requireRoomMember(roomId, userId); + if (!access) return res.status(404).json({ error: "Room not found" }); + if (!access.isHost && access.room.presenterUserId !== userId) { + return res.status(403).json({ + error: "Only the presenter or host can review control requests", + }); + } + res.json({ requests: await collabRuntimeStore.listRequests(roomId) }); + } catch (error) { + sshLogger.error("Failed to list collab control requests", error, { + operation: "collab_control_requests_list_error", + }); + res.status(500).json({ error: "Failed to list control requests" }); + } + }, +); + +router.delete( + "/rooms/:id/control/requests/:userId", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId!; + const roomId = String(req.params.id); + const targetId = String(req.params.userId); + try { + const access = await requireRoomMember(roomId, userId); + if (!access) return res.status(404).json({ error: "Room not found" }); + const mayReview = access.isHost || access.room.presenterUserId === userId; + if (!mayReview && targetId !== userId) { + return res + .status(403) + .json({ error: "Not allowed to dismiss request" }); + } + await collabRuntimeStore.removeRequest(roomId, targetId); + collabRoomHub.broadcast(roomId, { + type: "collab_control_requests_changed", + roomId, + }); + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: + targetId === userId + ? "collab_control_request_cancel" + : "collab_control_request_dismiss", + resourceType: "collab_room", + resourceId: roomId, + resourceName: access.room.name, + details: JSON.stringify({ targetUserId: targetId }), + ipAddress, + userAgent, + success: true, + }); + res.json({ success: true }); + } catch (error) { + sshLogger.error("Failed to dismiss collab control request", error, { + operation: "collab_control_request_dismiss_error", + }); + res.status(500).json({ error: "Failed to dismiss control request" }); + } + }, +); + /** * @openapi * /collab/rooms/{id}/guest-link: @@ -961,7 +1104,8 @@ router.post( } await revokeStageShare(access.room); - setStageController(roomId, null); + await setStageController(roomId, null); + await collabRuntimeStore.clearRequests(roomId); const repository = createCurrentCollabRoomRepository(); if (access.room.persistent) { await repository.setGuestToken(roomId, null); diff --git a/src/backend/hosts/collab/runtime-store.ts b/src/backend/hosts/collab/runtime-store.ts new file mode 100644 index 00000000..51b98be5 --- /dev/null +++ b/src/backend/hosts/collab/runtime-store.ts @@ -0,0 +1,286 @@ +import crypto from "crypto"; +import { createClient } from "redis"; +import { sshLogger } from "../../utils/logger.js"; + +export interface CollabControlRequest { + userId: string; + username: string; + requestedAt: string; +} + +interface PresenceEntry { + instanceId: string; + userId: string; + username: string; +} + +interface EventEnvelope { + source: string; + roomId: string; + message: object; +} + +const KEY_PREFIX = process.env.TERMIX_REDIS_PREFIX?.trim() || "termix:collab"; +const EVENT_CHANNEL = `${KEY_PREFIX}:events`; +const PRESENCE_TTL_MS = 45_000; +const STATE_TTL_SECONDS = 12 * 60 * 60; +const CONNECT_RETRY_MS = 15_000; + +export class CollabRuntimeStore { + private readonly instanceId = crypto.randomUUID(); + private publisher: ReturnType | null = null; + private subscriber: ReturnType | null = null; + private connecting: Promise | null = null; + private nextConnectAttempt = 0; + private eventListeners = new Set<(roomId: string, message: object) => void>(); + private localControllers = new Map(); + private localRequests = new Map>(); + private presenceMembers = new Map>(); + + onEvent(listener: (roomId: string, message: object) => void): void { + this.eventListeners.add(listener); + void this.ensureConnected(); + } + + async publish(roomId: string, message: object): Promise { + if (!(await this.ensureConnected()) || !this.publisher) return; + await this.publisher + .publish( + EVENT_CHANNEL, + JSON.stringify({ source: this.instanceId, roomId, message }), + ) + .catch((error) => this.logRedisFailure("publish", error)); + } + + async getController(roomId: string): Promise { + if (await this.ensureConnected()) { + const value = await this.publisher + ?.get(`${KEY_PREFIX}:controller:${roomId}`) + .catch((error) => { + this.logRedisFailure("get_controller", error); + return undefined; + }); + if (value !== undefined) return value; + } + return this.localControllers.get(roomId) ?? null; + } + + async setController(roomId: string, userId: string | null): Promise { + if (userId) this.localControllers.set(roomId, userId); + else this.localControllers.delete(roomId); + if (!(await this.ensureConnected()) || !this.publisher) return; + const key = `${KEY_PREFIX}:controller:${roomId}`; + const operation = userId + ? this.publisher.set(key, userId, { EX: STATE_TTL_SECONDS }) + : this.publisher.del(key); + await operation.catch((error) => + this.logRedisFailure("set_controller", error), + ); + } + + async listRequests(roomId: string): Promise { + if (await this.ensureConnected()) { + const values = await this.publisher + ?.hVals(`${KEY_PREFIX}:requests:${roomId}`) + .catch((error) => { + this.logRedisFailure("list_requests", error); + return null; + }); + if (values) { + return values + .flatMap((value) => this.parse(value) ?? []) + .sort((a, b) => a.requestedAt.localeCompare(b.requestedAt)); + } + } + return Array.from(this.localRequests.get(roomId)?.values() ?? []).sort( + (a, b) => a.requestedAt.localeCompare(b.requestedAt), + ); + } + + async upsertRequest( + roomId: string, + request: CollabControlRequest, + ): Promise { + let requests = this.localRequests.get(roomId); + if (!requests) { + requests = new Map(); + this.localRequests.set(roomId, requests); + } + requests.set(request.userId, request); + if (!(await this.ensureConnected()) || !this.publisher) return; + const key = `${KEY_PREFIX}:requests:${roomId}`; + await this.publisher + .multi() + .hSet(key, request.userId, JSON.stringify(request)) + .expire(key, STATE_TTL_SECONDS) + .exec() + .catch((error) => this.logRedisFailure("upsert_request", error)); + } + + async removeRequest(roomId: string, userId: string): Promise { + const requests = this.localRequests.get(roomId); + requests?.delete(userId); + if (requests?.size === 0) this.localRequests.delete(roomId); + if (!(await this.ensureConnected()) || !this.publisher) return; + await this.publisher + .hDel(`${KEY_PREFIX}:requests:${roomId}`, userId) + .catch((error) => this.logRedisFailure("remove_request", error)); + } + + async clearRequests(roomId: string): Promise { + this.localRequests.delete(roomId); + if (!(await this.ensureConnected()) || !this.publisher) return; + await this.publisher + .del(`${KEY_PREFIX}:requests:${roomId}`) + .catch((error) => this.logRedisFailure("clear_requests", error)); + } + + async updatePresence( + roomId: string, + users: Array<{ userId: string; username: string }>, + ): Promise { + if (!(await this.ensureConnected()) || !this.publisher) return; + const key = `${KEY_PREFIX}:presence:${roomId}`; + const previous = this.presenceMembers.get(roomId) ?? new Set(); + const expiresAt = Date.now() + PRESENCE_TTL_MS; + const current = new Set( + users.map((user) => + JSON.stringify({ + instanceId: this.instanceId, + ...user, + } satisfies PresenceEntry), + ), + ); + const transaction = this.publisher.multi(); + if (previous.size > 0) transaction.zRem(key, Array.from(previous)); + for (const member of current) { + transaction.zAdd(key, { score: expiresAt, value: member }); + } + transaction.expire(key, Math.ceil(PRESENCE_TTL_MS / 1000) * 2); + await transaction + .exec() + .then(() => { + if (current.size > 0) this.presenceMembers.set(roomId, current); + else this.presenceMembers.delete(roomId); + }) + .catch((error) => this.logRedisFailure("update_presence", error)); + } + + async onlineUsers( + roomId: string, + localUsers: Array<{ userId: string; username: string }>, + ): Promise> { + if (!(await this.ensureConnected()) || !this.publisher) return localUsers; + const key = `${KEY_PREFIX}:presence:${roomId}`; + const now = Date.now(); + const transaction = this.publisher + .multi() + .zRemRangeByScore(key, 0, now) + .zRangeByScore(key, now + 1, "+inf"); + const result = await transaction.exec().catch((error) => { + this.logRedisFailure("online_users", error); + return null; + }); + const members = result?.[1] as string[] | undefined; + if (!members) return localUsers; + const seen = new Map(); + for (const member of members) { + const entry = this.parse(member); + if (entry) seen.set(entry.userId, entry.username); + } + return Array.from(seen, ([userId, username]) => ({ userId, username })); + } + + async close(): Promise { + await Promise.allSettled([ + this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(), + this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(), + ]); + this.publisher = null; + this.subscriber = null; + } + + private async ensureConnected(): Promise { + const url = process.env.REDIS_URL?.trim(); + if (!url) return false; + if (this.publisher?.isReady && this.subscriber?.isReady) return true; + if (Date.now() < this.nextConnectAttempt) return false; + if (this.connecting) return this.connecting; + this.connecting = this.connect(url).finally(() => { + this.connecting = null; + }); + return this.connecting; + } + + private async connect(url: string): Promise { + try { + this.publisher = createClient({ + url, + socket: { connectTimeout: 1500, reconnectStrategy: false }, + }); + this.subscriber = this.publisher.duplicate(); + this.publisher.on("error", (error) => + this.logRedisFailure("client", error), + ); + this.subscriber.on("error", (error) => + this.logRedisFailure("subscriber", error), + ); + await Promise.all([this.publisher.connect(), this.subscriber.connect()]); + await this.subscriber.subscribe(EVENT_CHANNEL, (raw) => { + const envelope = this.parse(raw); + if ( + !envelope || + typeof envelope.source !== "string" || + typeof envelope.roomId !== "string" || + !envelope.message || + typeof envelope.message !== "object" || + envelope.source === this.instanceId + ) { + return; + } + for (const listener of this.eventListeners) { + try { + listener(envelope.roomId, envelope.message); + } catch (error) { + this.logRedisFailure("event_listener", error); + } + } + }); + sshLogger.info("Collaboration Redis runtime connected", { + operation: "collab_redis_connected", + }); + this.nextConnectAttempt = 0; + return true; + } catch (error) { + this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS; + this.logRedisFailure("connect", error); + await Promise.allSettled([ + this.publisher?.disconnect(), + this.subscriber?.disconnect(), + ]); + this.publisher = null; + this.subscriber = null; + return false; + } + } + + private parse(value: string): T | null { + try { + return JSON.parse(value) as T; + } catch { + return null; + } + } + + private logRedisFailure(operation: string, error: unknown): void { + sshLogger.warn( + "Collaboration Redis runtime unavailable; using local state", + { + operation: `collab_redis_${operation}`, + error: error instanceof Error ? error.message : String(error), + }, + ); + } +} + +export const collabRuntimeStore = new CollabRuntimeStore(); diff --git a/src/backend/hosts/collab/stage-control.ts b/src/backend/hosts/collab/stage-control.ts index 4e318888..5961cf6a 100644 --- a/src/backend/hosts/collab/stage-control.ts +++ b/src/backend/hosts/collab/stage-control.ts @@ -1,20 +1,18 @@ /** * Who besides the presenter may drive the current stage, per room. * - * Deliberately in-memory: control is a property of the live stage, and the - * stage itself (SSH session / guacd connection) is process-local already. - * Every stage switch clears it. + * Redis-backed when REDIS_URL is configured, with a local fallback for + * single-instance deployments. Every stage switch clears it. */ -const controllers = new Map(); +import { collabRuntimeStore } from "./runtime-store.js"; -export function getStageController(roomId: string): string | null { - return controllers.get(roomId) ?? null; +export function getStageController(roomId: string): Promise { + return collabRuntimeStore.getController(roomId); } export function setStageController( roomId: string, userId: string | null, -): void { - if (userId) controllers.set(roomId, userId); - else controllers.delete(roomId); +): Promise { + return collabRuntimeStore.setController(roomId, userId); } diff --git a/src/backend/tests/hosts/collab/room-hub.test.ts b/src/backend/tests/hosts/collab/room-hub.test.ts index 77a91773..29fb49aa 100644 --- a/src/backend/tests/hosts/collab/room-hub.test.ts +++ b/src/backend/tests/hosts/collab/room-hub.test.ts @@ -1,5 +1,26 @@ import { describe, expect, it, vi } from "vitest"; import type { WebSocket } from "ws"; + +const runtime = vi.hoisted(() => ({ + listener: null as ((roomId: string, message: object) => void) | null, + publish: vi.fn(async () => undefined), + updatePresence: vi.fn(async () => undefined), +})); + +vi.mock("../../../hosts/collab/runtime-store.js", () => ({ + collabRuntimeStore: { + onEvent: (listener: (roomId: string, message: object) => void) => { + runtime.listener = listener; + }, + publish: runtime.publish, + updatePresence: runtime.updatePresence, + onlineUsers: async ( + _roomId: string, + users: Array<{ userId: string; username: string }>, + ) => users, + }, +})); + import { collabRoomHub } from "../../../hosts/collab/room-hub.js"; function fakeWs(open = true): WebSocket { @@ -11,7 +32,7 @@ function fakeWs(open = true): WebSocket { } describe("collabRoomHub", () => { - it("announces the online list on subscribe and unsubscribe, deduplicated per user", () => { + it("announces the online list on subscribe and unsubscribe, deduplicated per user", async () => { const a1 = fakeWs(); const a2 = fakeWs(); const b = fakeWs(); @@ -19,11 +40,12 @@ describe("collabRoomHub", () => { collabRoomHub.subscribe("room-1", { ws: a2, userId: "a", username: "A" }); collabRoomHub.subscribe("room-1", { ws: b, userId: "b", username: "B" }); - expect(collabRoomHub.onlineUsers("room-1")).toEqual([ + expect(await collabRoomHub.onlineUsers("room-1")).toEqual([ { userId: "a", username: "A" }, { userId: "b", username: "B" }, ]); + await vi.waitFor(() => expect(b.send).toHaveBeenCalled()); const last = JSON.parse( (b.send as ReturnType).mock.calls.at(-1)?.[0] as string, ); @@ -37,20 +59,22 @@ describe("collabRoomHub", () => { }); collabRoomHub.unsubscribe(a1); - expect(collabRoomHub.onlineUsers("room-1")).toHaveLength(2); + expect(await collabRoomHub.onlineUsers("room-1")).toHaveLength(2); collabRoomHub.unsubscribe(a2); - expect(collabRoomHub.onlineUsers("room-1")).toEqual([ + expect(await collabRoomHub.onlineUsers("room-1")).toEqual([ { userId: "b", username: "B" }, ]); collabRoomHub.unsubscribe(b); - expect(collabRoomHub.onlineUsers("room-1")).toEqual([]); + expect(await collabRoomHub.onlineUsers("room-1")).toEqual([]); }); - it("subscribing the same socket twice keeps one subscription", () => { + it("subscribing the same socket twice keeps one subscription", async () => { const ws = fakeWs(); collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" }); collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" }); - expect((ws.send as ReturnType).mock.calls).toHaveLength(1); + await vi.waitFor(() => + expect((ws.send as ReturnType).mock.calls).toHaveLength(1), + ); collabRoomHub.unsubscribe(ws); }); @@ -74,4 +98,16 @@ describe("collabRoomHub", () => { collabRoomHub.unsubscribe(open); collabRoomHub.unsubscribe(closed); }); + + it("fans out remote Redis events but keeps internal events server-side", () => { + const ws = fakeWs(); + collabRoomHub.subscribe("room-4", { ws, userId: "a", username: "A" }); + (ws.send as ReturnType).mockClear(); + + runtime.listener?.("room-4", { type: "collab_members_changed" }); + runtime.listener?.("room-4", { type: "collab_internal_stage_revoked" }); + + expect(ws.send).toHaveBeenCalledTimes(1); + collabRoomHub.unsubscribe(ws); + }); }); diff --git a/src/backend/tests/hosts/collab/routes.test.ts b/src/backend/tests/hosts/collab/routes.test.ts index 928ca68c..6bb5c9d5 100644 --- a/src/backend/tests/hosts/collab/routes.test.ts +++ b/src/backend/tests/hosts/collab/routes.test.ts @@ -29,6 +29,11 @@ const state = vi.hoisted(() => ({ liveOwned: new Map(), // sessionId -> owner broadcasts: [] as Array>, control: [] as Array, + controllers: new Map(), + requests: new Map< + string, + Map + >(), })); vi.mock("../../../utils/logger.js", () => ({ @@ -67,6 +72,39 @@ vi.mock("../../../hosts/collab/room-hub.js", () => ({ onlineUsers: () => [], }, })); +vi.mock("../../../hosts/collab/runtime-store.js", () => ({ + collabRuntimeStore: { + onEvent: vi.fn(), + publish: vi.fn(async () => undefined), + getController: async (roomId: string) => + state.controllers.get(roomId) ?? null, + setController: async (roomId: string, userId: string | null) => { + if (userId) state.controllers.set(roomId, userId); + else state.controllers.delete(roomId); + }, + listRequests: async (roomId: string) => + Array.from(state.requests.get(roomId)?.values() ?? []).sort((a, b) => + a.requestedAt.localeCompare(b.requestedAt), + ), + upsertRequest: async ( + roomId: string, + request: { userId: string; username: string; requestedAt: string }, + ) => { + let requests = state.requests.get(roomId); + if (!requests) { + requests = new Map(); + state.requests.set(roomId, requests); + } + requests.set(request.userId, request); + }, + removeRequest: async (roomId: string, userId: string) => { + state.requests.get(roomId)?.delete(userId); + }, + clearRequests: async (roomId: string) => { + state.requests.delete(roomId); + }, + }, +})); vi.mock("../../../hosts/terminal/session-manager.js", () => ({ sessionManager: { setRoomShareControl: (...args: unknown[]) => { @@ -302,6 +340,8 @@ describe("collab room routes", () => { state.liveOwned.clear(); state.broadcasts.length = 0; state.control.length = 0; + state.controllers.clear(); + state.requests.clear(); state.sharingEnabled = true; }); @@ -434,13 +474,13 @@ describe("collab room routes", () => { params: { id: roomId }, body: { userId: "alice" }, }); - expect(getStageController(roomId)).toBe("alice"); + expect(await getStageController(roomId)).toBe("alice"); await as("alice", () => present(roomId, "s2")); const room = state.rooms.get(roomId)!; expect(room.presenterUserId).toBe("alice"); expect(state.shares.get(firstShare)!.revokedAt).toBeTruthy(); - expect(getStageController(roomId)).toBeNull(); + expect(await getStageController(roomId)).toBeNull(); }); it("stop is for the presenter or host", async () => { @@ -533,14 +573,16 @@ describe("collab room routes", () => { state.rooms.get(roomId)!.stageShareId, "alice", ]); - expect(state.broadcasts.at(-1)).toMatchObject({ - type: "collab_control_changed", - controllerUserId: "alice", - }); + expect(state.broadcasts).toContainEqual( + expect.objectContaining({ + type: "collab_control_changed", + controllerUserId: "alice", + }), + ); expect((await control("bob", null)).statusCode).toBe(403); expect((await control("alice", null)).statusCode).toBe(200); - expect(getStageController(roomId)).toBeNull(); + expect(await getStageController(roomId)).toBeNull(); const asked = await as("bob", () => invoke("post", "/rooms/:id/control/request", { params: { id: roomId } }), @@ -551,6 +593,31 @@ describe("collab room routes", () => { userId: "bob", username: "BOB", }); + expect( + ( + await as("bob", () => + invoke("get", "/rooms/:id", { params: { id: roomId } }), + ) + ).jsonBody!.controlRequests, + ).toEqual([expect.objectContaining({ userId: "bob" })]); + + const repeated = await as("bob", () => + invoke("post", "/rooms/:id/control/request", { + params: { id: roomId }, + }), + ); + expect((repeated as { statusCode: number }).statusCode).toBe(429); + + const listed = await invoke("get", "/rooms/:id/control/requests", { + params: { id: roomId }, + }); + expect(listed.jsonBody!.requests).toEqual([ + expect.objectContaining({ userId: "bob" }), + ]); + await invoke("delete", "/rooms/:id/control/requests/:userId", { + params: { id: roomId, userId: "bob" }, + }); + expect(state.requests.get(roomId)?.size ?? 0).toBe(0); }); it("guest link: host-only toggle, anonymous resolve follows the stage, rate limited", async () => { diff --git a/src/backend/tests/hosts/collab/runtime-store.test.ts b/src/backend/tests/hosts/collab/runtime-store.test.ts new file mode 100644 index 00000000..2443eacb --- /dev/null +++ b/src/backend/tests/hosts/collab/runtime-store.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { collabRuntimeStore } from "../../../hosts/collab/runtime-store.js"; + +describe("collabRuntimeStore local fallback", () => { + it("stores stage control and an ordered, deduplicated request queue", async () => { + const roomId = `fallback-${crypto.randomUUID()}`; + + await collabRuntimeStore.setController(roomId, "alice"); + expect(await collabRuntimeStore.getController(roomId)).toBe("alice"); + + await collabRuntimeStore.upsertRequest(roomId, { + userId: "bob", + username: "Bob", + requestedAt: "2026-08-25T00:00:02.000Z", + }); + await collabRuntimeStore.upsertRequest(roomId, { + userId: "alice", + username: "Alice", + requestedAt: "2026-08-25T00:00:01.000Z", + }); + await collabRuntimeStore.upsertRequest(roomId, { + userId: "bob", + username: "Bob", + requestedAt: "2026-08-25T00:00:03.000Z", + }); + + expect(await collabRuntimeStore.listRequests(roomId)).toEqual([ + expect.objectContaining({ userId: "alice" }), + expect.objectContaining({ userId: "bob" }), + ]); + await collabRuntimeStore.removeRequest(roomId, "alice"); + expect(await collabRuntimeStore.listRequests(roomId)).toHaveLength(1); + + await collabRuntimeStore.clearRequests(roomId); + await collabRuntimeStore.setController(roomId, null); + expect(await collabRuntimeStore.listRequests(roomId)).toEqual([]); + expect(await collabRuntimeStore.getController(roomId)).toBeNull(); + }); +}); diff --git a/src/ui/api/collab-api.ts b/src/ui/api/collab-api.ts index 289ac472..291d8f7f 100644 --- a/src/ui/api/collab-api.ts +++ b/src/ui/api/collab-api.ts @@ -46,6 +46,13 @@ export interface CollabRoomDetail { online: CollabOnlineUser[]; stage: CollabStage; controllerUserId: string | null; + controlRequests: CollabControlRequest[]; +} + +export interface CollabControlRequest { + userId: string; + username: string; + requestedAt: string; } export async function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> { @@ -145,14 +152,43 @@ export async function setCollabStageControl( } } -export async function requestCollabStageControl(roomId: string): Promise { +export async function requestCollabStageControl( + roomId: string, +): Promise<{ request: CollabControlRequest }> { try { - await authApi.post(`/collab/rooms/${roomId}/control/request`); + const response = await authApi.post( + `/collab/rooms/${roomId}/control/request`, + ); + return response.data; } catch (error) { throw handleApiError(error, "request stage control"); } } +export async function listCollabControlRequests( + roomId: string, +): Promise<{ requests: CollabControlRequest[] }> { + try { + const response = await authApi.get( + `/collab/rooms/${roomId}/control/requests`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "list control requests"); + } +} + +export async function dismissCollabControlRequest( + roomId: string, + userId: string, +): Promise { + try { + await authApi.delete(`/collab/rooms/${roomId}/control/requests/${userId}`); + } catch (error) { + throw handleApiError(error, "dismiss control request"); + } +} + export async function endCollabRoom(roomId: string): Promise { try { await authApi.post(`/collab/rooms/${roomId}/end`); diff --git a/src/ui/features/collab/CollabMembersSidebar.tsx b/src/ui/features/collab/CollabMembersSidebar.tsx new file mode 100644 index 00000000..f93ea052 --- /dev/null +++ b/src/ui/features/collab/CollabMembersSidebar.tsx @@ -0,0 +1,287 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Check, + Crown, + Hand, + MonitorUp, + MousePointerClick, + UserMinus, + Users, + X, +} from "lucide-react"; +import { Badge } from "@/components/badge"; +import { Button } from "@/components/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/alert-dialog"; +import type { CollabControlRequest, CollabRoomDetail } from "@/api/collab-api"; + +interface CollabMembersSidebarProps { + detail: CollabRoomDetail; + onClose: () => void; + onInvite: () => void; + onControl: (userId: string | null) => Promise; + onDismissRequest: (userId: string) => Promise; + onRemoveMember: (userId: string) => Promise; +} + +export function CollabMembersSidebar({ + detail, + onClose, + onInvite, + onControl, + onDismissRequest, + onRemoveMember, +}: CollabMembersSidebarProps) { + const { t } = useTranslation(); + const [removing, setRemoving] = useState<{ + userId: string; + username: string; + } | null>(null); + const [busyUserId, setBusyUserId] = useState(null); + const onlineIds = new Set(detail.online.map((user) => user.userId)); + const presenterUserId = detail.stage.presenterUserId; + const canManageControl = detail.isHost || presenterUserId === detail.me; + const canControl = + canManageControl && + detail.stage.protocol === "ssh" && + !!detail.stage.shareId; + + async function run(userId: string, action: () => Promise) { + setBusyUserId(userId); + try { + await action(); + } finally { + setBusyUserId(null); + } + } + + async function grant(request: CollabControlRequest) { + await run(request.userId, async () => { + await onControl(request.userId); + }); + } + + return ( + + ); +} diff --git a/src/ui/features/collab/CollabRoomTab.tsx b/src/ui/features/collab/CollabRoomTab.tsx index 654ff140..c08c10ed 100644 --- a/src/ui/features/collab/CollabRoomTab.tsx +++ b/src/ui/features/collab/CollabRoomTab.tsx @@ -3,18 +3,15 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { AlertCircle, - Crown, Hand, Link2, Loader2, MonitorUp, - MousePointerClick, Presentation, Square, - UserPlus, + Users, } from "lucide-react"; import { Button } from "@/components/button"; -import { Badge } from "@/components/badge"; import { Dialog, DialogContent, @@ -33,6 +30,7 @@ import { AlertDialogTitle, } from "@/components/alert-dialog"; import { Input } from "@/components/input"; +import { CollabMembersSidebar } from "./CollabMembersSidebar"; import { Terminal } from "@/features/terminal/Terminal"; import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext"; import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; @@ -45,11 +43,13 @@ import { isElectron } from "@/lib/electron"; import { getErrorMessage } from "@/lib/error-message"; import { endCollabRoom, + dismissCollabControlRequest, getCollabRoom, getCollabStage, inviteCollabMembers, presentCollabStage, requestCollabStageControl, + removeCollabMember, setCollabGuestLink, setCollabStageControl, stopCollabStage, @@ -108,6 +108,7 @@ export function CollabRoomTab({ const [presentOpen, setPresentOpen] = useState(false); const [presentLoading, setPresentLoading] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); + const [membersOpen, setMembersOpen] = useState(true); const [endOpen, setEndOpen] = useState(false); const [takeoverChoice, setTakeoverChoice] = useState( null, @@ -224,19 +225,10 @@ export function CollabRoomTab({ case "collab_members_changed": case "collab_stage_changed": case "collab_control_changed": + case "collab_control_requested": + case "collab_control_requests_changed": void refresh(); break; - case "collab_control_requested": { - const request = msg as unknown as { - userId: string; - username?: string; - }; - handleControlRequestRef.current?.( - request.userId, - request.username ?? "?", - ); - break; - } case "collab_room_ended": setEnded(true); break; @@ -270,30 +262,49 @@ export function CollabRoomTab({ const controllerUserId = detail?.controllerUserId ?? null; const presenterUserId = detail?.stage.presenterUserId ?? null; const iAmPresenter = !!me && presenterUserId === me; - const onlineIds = new Set(detail?.online.map((user) => user.userId)); const presenterName = detail?.members.find( (member) => member.userId === presenterUserId, )?.username; - const handleControlRequestRef = useRef< - ((userId: string, username: string) => void) | null - >(null); - handleControlRequestRef.current = (userId, username) => { - if (!roomId) return; - const mayGrant = isHost || iAmPresenter; - if (!mayGrant || userId === me) return; - toast(t("collab.controlRequestedBy", { name: username }), { - action: { - label: t("collab.grant"), - onClick: () => void setCollabStageControl(roomId, userId), - }, - }); - }; - async function changeControl(targetId: string | null) { if (!roomId) return; try { await setCollabStageControl(roomId, targetId); + await refresh(); + } catch (error) { + toast.error(getErrorMessage(error)); + } + } + + async function dismissControlRequest(targetId: string) { + if (!roomId) return; + try { + await dismissCollabControlRequest(roomId, targetId); + await refresh(); + } catch (error) { + toast.error(getErrorMessage(error)); + } + } + + async function removeMember(targetId: string) { + if (!roomId) return; + try { + await removeCollabMember(roomId, targetId); + await refresh(); + } catch (error) { + toast.error(getErrorMessage(error)); + } + } + + async function toggleOwnControlRequest() { + if (!roomId || !me) return; + try { + const existing = detail?.controlRequests.some( + (request) => request.userId === me, + ); + if (existing) await dismissCollabControlRequest(roomId, me); + else await requestCollabStageControl(roomId); + await refresh(); } catch (error) { toast.error(getErrorMessage(error)); } @@ -507,70 +518,14 @@ export function CollabRoomTab({ )} - {/* Header: roster + controls */} + {/* Header: room identity + primary controls */}
{detail?.room.name} -
- {detail?.members.map((member) => { - const canToggleControl = - (isHost || iAmPresenter) && - detail?.stage.protocol === "ssh" && - !!detail?.stage.shareId && - member.userId !== presenterUserId; - const hasControl = member.userId === controllerUserId; - const badge = ( - - - {member.username} - {member.roomRole === "host" && } - {member.userId === presenterUserId && ( - - )} - {hasControl && } - - ); - return canToggleControl ? ( - - ) : ( - badge - ); - })} -
+
- {isHost && ( - - )} {!!detail?.stage.shareId && detail.stage.protocol === "ssh" && !iAmPresenter && @@ -582,15 +537,17 @@ export function CollabRoomTab({ onClick={() => controllerUserId === me ? void changeControl(null) - : void requestCollabStageControl(roomId).catch((error) => - toast.error(getErrorMessage(error)), - ) + : void toggleOwnControlRequest() } > {controllerUserId === me ? t("collab.releaseControl") - : t("collab.requestControl")} + : detail.controlRequests.some( + (request) => request.userId === me, + ) + ? t("collab.cancelControlRequest") + : t("collab.requestControl")} )} {(iAmPresenter || draft || (isHost && presenterUserId)) && ( @@ -604,6 +561,17 @@ export function CollabRoomTab({ {t("collab.stopPresenting")} )} +