mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
feat: improve collaboration rooms (#1338)
This commit is contained in:
@@ -354,6 +354,12 @@ networks:
|
|||||||
driver: bridge
|
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
|
### Command Line Interface
|
||||||
|
|
||||||
Termix also has a CLI, so you can manage your servers from a terminal and use Termix in your own scripts.
|
Termix also has a CLI, so you can manage your servers from a terminal and use Termix in your own scripts.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ services:
|
|||||||
GUACD_TUNNEL_HOST: "termix-dev"
|
GUACD_TUNNEL_HOST: "termix-dev"
|
||||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||||
|
# REDIS_URL: "redis://redis:6379"
|
||||||
|
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd-dev
|
- guacd-dev
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ services:
|
|||||||
# Where guacd stores files for RDP drive redirection (one folder per
|
# Where guacd stores files for RDP drive redirection (one folder per
|
||||||
# user is created underneath). Must be writable by guacd's user.
|
# user is created underneath). Must be writable by guacd's user.
|
||||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
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
|
# Hardened deployments can require keys from environment variables or
|
||||||
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
|
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
|
||||||
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
|
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
|
||||||
|
|||||||
@@ -350,6 +350,11 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
|
||||||
|
`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件;
|
||||||
|
实时 SSH 与远程桌面传输仍依附于创建连接的后端实例,因此负载均衡器需要保持
|
||||||
|
WebSocket 会话亲和性。单实例部署无需 Redis。
|
||||||
|
|
||||||
### 命令行工具
|
### 命令行工具
|
||||||
|
|
||||||
Termix 还提供命令行工具,你可以在终端里管理服务器,也可以把 Termix 用在自己的脚本中。
|
Termix 还提供命令行工具,你可以在终端里管理服务器,也可以把 Termix 用在自己的脚本中。
|
||||||
|
|||||||
Generated
+99
-1
@@ -7,7 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "2.7.1",
|
"version": "2.7.1",
|
||||||
"hasInstallScript": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.116.0",
|
"@anthropic-ai/sdk": "^0.116.0",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
@@ -39,6 +38,7 @@
|
|||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"redis": "^6.2.1",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
@@ -5647,6 +5647,78 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@replit/codemirror-lang-nix": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@replit/codemirror-lang-nix/-/codemirror-lang-nix-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@replit/codemirror-lang-nix/-/codemirror-lang-nix-6.0.1.tgz",
|
||||||
@@ -8658,6 +8730,7 @@
|
|||||||
"version": "13.0.3",
|
"version": "13.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-addon-api": "^8.0.0"
|
"node-addon-api": "^8.0.0"
|
||||||
@@ -9180,6 +9253,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/cmdk": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||||
@@ -15882,6 +15964,22 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"redis": "^6.2.1",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
|
import { collabRuntimeStore } from "./runtime-store.js";
|
||||||
|
|
||||||
|
const PRESENCE_HEARTBEAT_MS = 15_000;
|
||||||
|
|
||||||
export interface CollabRoomClient {
|
export interface CollabRoomClient {
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
@@ -7,13 +10,28 @@ export interface CollabRoomClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory fan-out for collab room events, mirroring the single-instance
|
* Local WebSocket fan-out plus optional Redis pub/sub for multi-instance room
|
||||||
* assumption TerminalSessionManager already makes. REST mutations broadcast
|
* events and presence. The terminal WS server feeds subscribe/unsubscribe.
|
||||||
* through it; the terminal WS server feeds subscribe/unsubscribe.
|
|
||||||
*/
|
*/
|
||||||
class CollabRoomHub {
|
class CollabRoomHub {
|
||||||
private rooms = new Map<string, Set<CollabRoomClient>>();
|
private rooms = new Map<string, Set<CollabRoomClient>>();
|
||||||
|
|
||||||
|
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 {
|
subscribe(roomId: string, client: CollabRoomClient): void {
|
||||||
let clients = this.rooms.get(roomId);
|
let clients = this.rooms.get(roomId);
|
||||||
if (!clients) {
|
if (!clients) {
|
||||||
@@ -24,7 +42,7 @@ class CollabRoomHub {
|
|||||||
if (existing.ws === client.ws) return;
|
if (existing.ws === client.ws) return;
|
||||||
}
|
}
|
||||||
clients.add(client);
|
clients.add(client);
|
||||||
this.broadcastOnline(roomId);
|
void this.refreshPresence(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drops the socket from one room, or from every room when roomId is omitted. */
|
/** 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 (clients.size === 0) this.rooms.delete(id);
|
||||||
if (removed) this.broadcastOnline(id);
|
if (removed) void this.refreshPresence(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcast(roomId: string, message: object): void {
|
broadcast(roomId: string, message: object): void {
|
||||||
|
this.broadcastLocal(roomId, message);
|
||||||
|
void collabRuntimeStore.publish(roomId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onlineUsers(
|
||||||
|
roomId: string,
|
||||||
|
): Promise<Array<{ userId: string; username: string }>> {
|
||||||
|
return collabRuntimeStore.onlineUsers(
|
||||||
|
roomId,
|
||||||
|
this.localOnlineUsers(roomId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastLocal(roomId: string, message: object): void {
|
||||||
const clients = this.rooms.get(roomId);
|
const clients = this.rooms.get(roomId);
|
||||||
if (!clients) return;
|
if (!clients) return;
|
||||||
const payload = JSON.stringify(message);
|
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);
|
const clients = this.rooms.get(roomId);
|
||||||
if (!clients) return [];
|
if (!clients) return [];
|
||||||
const seen = new Map<string, string>();
|
const seen = new Map<string, string>();
|
||||||
@@ -67,11 +101,13 @@ class CollabRoomHub {
|
|||||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastOnline(roomId: string): void {
|
private async refreshPresence(roomId: string): Promise<void> {
|
||||||
|
const localUsers = this.localOnlineUsers(roomId);
|
||||||
|
await collabRuntimeStore.updatePresence(roomId, localUsers);
|
||||||
this.broadcast(roomId, {
|
this.broadcast(roomId, {
|
||||||
type: "collab_online",
|
type: "collab_online",
|
||||||
roomId,
|
roomId,
|
||||||
users: this.onlineUsers(roomId),
|
users: await this.onlineUsers(roomId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
|||||||
import { collabRoomHub } from "./room-hub.js";
|
import { collabRoomHub } from "./room-hub.js";
|
||||||
import { getStageController, setStageController } from "./stage-control.js";
|
import { getStageController, setStageController } from "./stage-control.js";
|
||||||
import { isCollabGuestRateLimited } from "./guest-rate-limit.js";
|
import { isCollabGuestRateLimited } from "./guest-rate-limit.js";
|
||||||
|
import {
|
||||||
|
collabRuntimeStore,
|
||||||
|
type CollabControlRequest,
|
||||||
|
} from "./runtime-store.js";
|
||||||
import { sessionManager } from "../terminal/session-manager.js";
|
import { sessionManager } from "../terminal/session-manager.js";
|
||||||
import {
|
import {
|
||||||
isLiveSession,
|
isLiveSession,
|
||||||
@@ -29,9 +33,8 @@ import type { CollabRoomRecord } from "../../database/repositories/collab-room-r
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Known limits, shared with session sharing v1:
|
* Known limits, shared with session sharing v1:
|
||||||
* - Room events and stage control live in this process (room-hub,
|
* - Redis synchronizes collaboration events and ephemeral state across
|
||||||
* stage-control). With more than one backend instance, members connected
|
* instances, but live session transports still require WebSocket affinity.
|
||||||
* to different instances do not see each other's events.
|
|
||||||
* - Guacamole stages stay read-only because guacamole-lite cannot revoke a
|
* - Guacamole stages stay read-only because guacamole-lite cannot revoke a
|
||||||
* writable viewer without disconnecting the whole shared session.
|
* writable viewer without disconnecting the whole shared session.
|
||||||
*/
|
*/
|
||||||
@@ -44,7 +47,6 @@ const STAGE_SHARE_EXPIRY_HOURS = 12;
|
|||||||
const MAX_INVITE_TARGETS = 200;
|
const MAX_INVITE_TARGETS = 200;
|
||||||
const CONTROL_REQUEST_COOLDOWN_MS = 5000;
|
const CONTROL_REQUEST_COOLDOWN_MS = 5000;
|
||||||
const PROTOCOLS: LiveProtocol[] = ["ssh", "rdp", "vnc", "telnet"];
|
const PROTOCOLS: LiveProtocol[] = ["ssh", "rdp", "vnc", "telnet"];
|
||||||
const controlRequestTimes = new Map<string, number>();
|
|
||||||
|
|
||||||
function isNonEmptyString(value: unknown): value is string {
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
@@ -74,6 +76,11 @@ async function revokeStageShare(room: CollabRoomRecord): Promise<void> {
|
|||||||
sessionManager.disconnectShareParticipants(share.sessionId, share.id, {
|
sessionManager.disconnectShareParticipants(share.sessionId, share.id, {
|
||||||
reason: "The collaboration stage ended",
|
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 =
|
const members =
|
||||||
await createCurrentCollabRoomRepository().listMembers(roomId);
|
await createCurrentCollabRoomRepository().listMembers(roomId);
|
||||||
|
const mayReviewControlRequests =
|
||||||
|
access.isHost || access.room.presenterUserId === userId;
|
||||||
|
const controlRequests = await collabRuntimeStore.listRequests(roomId);
|
||||||
res.json({
|
res.json({
|
||||||
room: publicRoom(access.room),
|
room: publicRoom(access.room),
|
||||||
me: userId,
|
me: userId,
|
||||||
isHost: access.isHost,
|
isHost: access.isHost,
|
||||||
members,
|
members,
|
||||||
online: collabRoomHub.onlineUsers(roomId),
|
online: await collabRoomHub.onlineUsers(roomId),
|
||||||
stage: stagePayload(access.room),
|
stage: stagePayload(access.room),
|
||||||
controllerUserId: getStageController(roomId),
|
controllerUserId: await getStageController(roomId),
|
||||||
|
controlRequests: mayReviewControlRequests
|
||||||
|
? controlRequests
|
||||||
|
: controlRequests.filter((request) => request.userId === userId),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Failed to get collab room", error, {
|
sshLogger.error("Failed to get collab room", error, {
|
||||||
@@ -332,6 +345,7 @@ router.delete(
|
|||||||
|
|
||||||
const repository = createCurrentCollabRoomRepository();
|
const repository = createCurrentCollabRoomRepository();
|
||||||
await repository.removeMember(roomId, targetId);
|
await repository.removeMember(roomId, targetId);
|
||||||
|
await collabRuntimeStore.removeRequest(roomId, targetId);
|
||||||
|
|
||||||
if (access.room.stageShareId && access.room.stageProtocol === "ssh") {
|
if (access.room.stageShareId && access.room.stageProtocol === "ssh") {
|
||||||
const share =
|
const share =
|
||||||
@@ -350,7 +364,7 @@ router.delete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getStageController(roomId) === targetId) {
|
if ((await getStageController(roomId)) === targetId) {
|
||||||
await applyStageControl(access.room, roomId, null);
|
await applyStageControl(access.room, roomId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +469,6 @@ router.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await revokeStageShare(access.room);
|
await revokeStageShare(access.room);
|
||||||
setStageController(roomId, null);
|
|
||||||
const repository = createCurrentCollabRoomRepository();
|
const repository = createCurrentCollabRoomRepository();
|
||||||
const replaced = await repository.replaceStage(
|
const replaced = await repository.replaceStage(
|
||||||
roomId,
|
roomId,
|
||||||
@@ -471,6 +484,8 @@ router.post(
|
|||||||
await shareRepository.revokeAsAdmin(share.id);
|
await shareRepository.revokeAsAdmin(share.id);
|
||||||
return res.status(409).json({ error: "The stage changed; try again" });
|
return res.status(409).json({ error: "The stage changed; try again" });
|
||||||
}
|
}
|
||||||
|
await setStageController(roomId, null);
|
||||||
|
await collabRuntimeStore.clearRequests(roomId);
|
||||||
|
|
||||||
const stage = {
|
const stage = {
|
||||||
presenterUserId: userId,
|
presenterUserId: userId,
|
||||||
@@ -534,7 +549,8 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await revokeStageShare(access.room);
|
await revokeStageShare(access.room);
|
||||||
setStageController(roomId, null);
|
await setStageController(roomId, null);
|
||||||
|
await collabRuntimeStore.clearRequests(roomId);
|
||||||
await createCurrentCollabRoomRepository().clearStage(roomId);
|
await createCurrentCollabRoomRepository().clearStage(roomId);
|
||||||
collabRoomHub.broadcast(roomId, {
|
collabRoomHub.broadcast(roomId, {
|
||||||
type: "collab_stage_changed",
|
type: "collab_stage_changed",
|
||||||
@@ -595,7 +611,7 @@ router.get(
|
|||||||
if (!share || !isLiveSession(protocol, share.sessionId)) {
|
if (!share || !isLiveSession(protocol, share.sessionId)) {
|
||||||
// The presenter is gone (expired share or dead session): clear the
|
// The presenter is gone (expired share or dead session): clear the
|
||||||
// stale stage so the room stops pointing at it.
|
// stale stage so the room stops pointing at it.
|
||||||
setStageController(roomId, null);
|
await setStageController(roomId, null);
|
||||||
await createCurrentCollabRoomRepository().clearStage(roomId);
|
await createCurrentCollabRoomRepository().clearStage(roomId);
|
||||||
collabRoomHub.broadcast(roomId, {
|
collabRoomHub.broadcast(roomId, {
|
||||||
type: "collab_stage_changed",
|
type: "collab_stage_changed",
|
||||||
@@ -605,7 +621,7 @@ router.get(
|
|||||||
return res.json({ stage: null });
|
return res.json({ stage: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const controllerUserId = getStageController(roomId);
|
const controllerUserId = await getStageController(roomId);
|
||||||
const stage: Record<string, unknown> = {
|
const stage: Record<string, unknown> = {
|
||||||
...stagePayload(room),
|
...stagePayload(room),
|
||||||
sessionId: share.sessionId,
|
sessionId: share.sessionId,
|
||||||
@@ -632,7 +648,7 @@ async function applyStageControl(
|
|||||||
roomId: string,
|
roomId: string,
|
||||||
controllerUserId: string | null,
|
controllerUserId: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
setStageController(roomId, controllerUserId);
|
await setStageController(roomId, controllerUserId);
|
||||||
if (room.stageShareId && room.stageProtocol === "ssh") {
|
if (room.stageShareId && room.stageProtocol === "ssh") {
|
||||||
try {
|
try {
|
||||||
const share = await createCurrentSessionShareRepository().findActiveById(
|
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
|
* @openapi
|
||||||
* /collab/rooms/{id}/control:
|
* /collab/rooms/{id}/control:
|
||||||
@@ -694,7 +753,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const releasingOwnControl =
|
const releasingOwnControl =
|
||||||
targetId === null && getStageController(roomId) === userId;
|
targetId === null && (await getStageController(roomId)) === userId;
|
||||||
const mayGrant = access.isHost || access.room.presenterUserId === userId;
|
const mayGrant = access.isHost || access.room.presenterUserId === userId;
|
||||||
if (!mayGrant && !releasingOwnControl) {
|
if (!mayGrant && !releasingOwnControl) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -710,6 +769,13 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await applyStageControl(access.room, roomId, targetId);
|
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);
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId,
|
userId,
|
||||||
@@ -760,20 +826,26 @@ router.post(
|
|||||||
error: "Remote desktop stages are read-only",
|
error: "Remote desktop stages are read-only",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const requestKey = `${roomId}:${userId}`;
|
const existing = (await collabRuntimeStore.listRequests(roomId)).find(
|
||||||
const now = Date.now();
|
(request) => request.userId === userId,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
now - (controlRequestTimes.get(requestKey) ?? 0) <
|
existing &&
|
||||||
|
Date.now() - Date.parse(existing.requestedAt) <
|
||||||
CONTROL_REQUEST_COOLDOWN_MS
|
CONTROL_REQUEST_COOLDOWN_MS
|
||||||
) {
|
) {
|
||||||
return res.status(429).json({ error: "Control was already requested" });
|
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, {
|
collabRoomHub.broadcast(roomId, {
|
||||||
type: "collab_control_requested",
|
type: "collab_control_requested",
|
||||||
roomId,
|
roomId,
|
||||||
userId,
|
...request,
|
||||||
username: await getAuditUsername(userId),
|
|
||||||
});
|
});
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -787,7 +859,7 @@ router.post(
|
|||||||
userAgent,
|
userAgent,
|
||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
res.json({ success: true });
|
res.json({ request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Failed to request collab stage control", error, {
|
sshLogger.error("Failed to request collab stage control", error, {
|
||||||
operation: "collab_control_request_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
|
* @openapi
|
||||||
* /collab/rooms/{id}/guest-link:
|
* /collab/rooms/{id}/guest-link:
|
||||||
@@ -961,7 +1104,8 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await revokeStageShare(access.room);
|
await revokeStageShare(access.room);
|
||||||
setStageController(roomId, null);
|
await setStageController(roomId, null);
|
||||||
|
await collabRuntimeStore.clearRequests(roomId);
|
||||||
const repository = createCurrentCollabRoomRepository();
|
const repository = createCurrentCollabRoomRepository();
|
||||||
if (access.room.persistent) {
|
if (access.room.persistent) {
|
||||||
await repository.setGuestToken(roomId, null);
|
await repository.setGuestToken(roomId, null);
|
||||||
|
|||||||
@@ -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<typeof createClient> | null = null;
|
||||||
|
private subscriber: ReturnType<typeof createClient> | null = null;
|
||||||
|
private connecting: Promise<boolean> | null = null;
|
||||||
|
private nextConnectAttempt = 0;
|
||||||
|
private eventListeners = new Set<(roomId: string, message: object) => void>();
|
||||||
|
private localControllers = new Map<string, string>();
|
||||||
|
private localRequests = new Map<string, Map<string, CollabControlRequest>>();
|
||||||
|
private presenceMembers = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
onEvent(listener: (roomId: string, message: object) => void): void {
|
||||||
|
this.eventListeners.add(listener);
|
||||||
|
void this.ensureConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(roomId: string, message: object): Promise<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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<CollabControlRequest[]> {
|
||||||
|
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<CollabControlRequest>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||||
|
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||||
|
const previous = this.presenceMembers.get(roomId) ?? new Set<string>();
|
||||||
|
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<Array<{ userId: string; username: string }>> {
|
||||||
|
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<string, string>();
|
||||||
|
for (const member of members) {
|
||||||
|
const entry = this.parse<PresenceEntry>(member);
|
||||||
|
if (entry) seen.set(entry.userId, entry.username);
|
||||||
|
}
|
||||||
|
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<EventEnvelope>(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<T>(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();
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Who besides the presenter may drive the current stage, per room.
|
* Who besides the presenter may drive the current stage, per room.
|
||||||
*
|
*
|
||||||
* Deliberately in-memory: control is a property of the live stage, and the
|
* Redis-backed when REDIS_URL is configured, with a local fallback for
|
||||||
* stage itself (SSH session / guacd connection) is process-local already.
|
* single-instance deployments. Every stage switch clears it.
|
||||||
* Every stage switch clears it.
|
|
||||||
*/
|
*/
|
||||||
const controllers = new Map<string, string>();
|
import { collabRuntimeStore } from "./runtime-store.js";
|
||||||
|
|
||||||
export function getStageController(roomId: string): string | null {
|
export function getStageController(roomId: string): Promise<string | null> {
|
||||||
return controllers.get(roomId) ?? null;
|
return collabRuntimeStore.getController(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setStageController(
|
export function setStageController(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
userId: string | null,
|
userId: string | null,
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (userId) controllers.set(roomId, userId);
|
return collabRuntimeStore.setController(roomId, userId);
|
||||||
else controllers.delete(roomId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { WebSocket } from "ws";
|
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";
|
import { collabRoomHub } from "../../../hosts/collab/room-hub.js";
|
||||||
|
|
||||||
function fakeWs(open = true): WebSocket {
|
function fakeWs(open = true): WebSocket {
|
||||||
@@ -11,7 +32,7 @@ function fakeWs(open = true): WebSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("collabRoomHub", () => {
|
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 a1 = fakeWs();
|
||||||
const a2 = fakeWs();
|
const a2 = fakeWs();
|
||||||
const b = 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: a2, userId: "a", username: "A" });
|
||||||
collabRoomHub.subscribe("room-1", { ws: b, userId: "b", username: "B" });
|
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: "a", username: "A" },
|
||||||
{ userId: "b", username: "B" },
|
{ userId: "b", username: "B" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(b.send).toHaveBeenCalled());
|
||||||
const last = JSON.parse(
|
const last = JSON.parse(
|
||||||
(b.send as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string,
|
(b.send as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string,
|
||||||
);
|
);
|
||||||
@@ -37,20 +59,22 @@ describe("collabRoomHub", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
collabRoomHub.unsubscribe(a1);
|
collabRoomHub.unsubscribe(a1);
|
||||||
expect(collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
|
expect(await collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
|
||||||
collabRoomHub.unsubscribe(a2);
|
collabRoomHub.unsubscribe(a2);
|
||||||
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
|
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||||
{ userId: "b", username: "B" },
|
{ userId: "b", username: "B" },
|
||||||
]);
|
]);
|
||||||
collabRoomHub.unsubscribe(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();
|
const ws = fakeWs();
|
||||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||||
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
|
await vi.waitFor(() =>
|
||||||
|
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1),
|
||||||
|
);
|
||||||
collabRoomHub.unsubscribe(ws);
|
collabRoomHub.unsubscribe(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,4 +98,16 @@ describe("collabRoomHub", () => {
|
|||||||
collabRoomHub.unsubscribe(open);
|
collabRoomHub.unsubscribe(open);
|
||||||
collabRoomHub.unsubscribe(closed);
|
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<typeof vi.fn>).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const state = vi.hoisted(() => ({
|
|||||||
liveOwned: new Map<string, string>(), // sessionId -> owner
|
liveOwned: new Map<string, string>(), // sessionId -> owner
|
||||||
broadcasts: [] as Array<Record<string, unknown>>,
|
broadcasts: [] as Array<Record<string, unknown>>,
|
||||||
control: [] as Array<unknown[]>,
|
control: [] as Array<unknown[]>,
|
||||||
|
controllers: new Map<string, string>(),
|
||||||
|
requests: new Map<
|
||||||
|
string,
|
||||||
|
Map<string, { userId: string; username: string; requestedAt: string }>
|
||||||
|
>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../utils/logger.js", () => ({
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
@@ -67,6 +72,39 @@ vi.mock("../../../hosts/collab/room-hub.js", () => ({
|
|||||||
onlineUsers: () => [],
|
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", () => ({
|
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
setRoomShareControl: (...args: unknown[]) => {
|
setRoomShareControl: (...args: unknown[]) => {
|
||||||
@@ -302,6 +340,8 @@ describe("collab room routes", () => {
|
|||||||
state.liveOwned.clear();
|
state.liveOwned.clear();
|
||||||
state.broadcasts.length = 0;
|
state.broadcasts.length = 0;
|
||||||
state.control.length = 0;
|
state.control.length = 0;
|
||||||
|
state.controllers.clear();
|
||||||
|
state.requests.clear();
|
||||||
state.sharingEnabled = true;
|
state.sharingEnabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,13 +474,13 @@ describe("collab room routes", () => {
|
|||||||
params: { id: roomId },
|
params: { id: roomId },
|
||||||
body: { userId: "alice" },
|
body: { userId: "alice" },
|
||||||
});
|
});
|
||||||
expect(getStageController(roomId)).toBe("alice");
|
expect(await getStageController(roomId)).toBe("alice");
|
||||||
|
|
||||||
await as("alice", () => present(roomId, "s2"));
|
await as("alice", () => present(roomId, "s2"));
|
||||||
const room = state.rooms.get(roomId)!;
|
const room = state.rooms.get(roomId)!;
|
||||||
expect(room.presenterUserId).toBe("alice");
|
expect(room.presenterUserId).toBe("alice");
|
||||||
expect(state.shares.get(firstShare)!.revokedAt).toBeTruthy();
|
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 () => {
|
it("stop is for the presenter or host", async () => {
|
||||||
@@ -533,14 +573,16 @@ describe("collab room routes", () => {
|
|||||||
state.rooms.get(roomId)!.stageShareId,
|
state.rooms.get(roomId)!.stageShareId,
|
||||||
"alice",
|
"alice",
|
||||||
]);
|
]);
|
||||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
expect(state.broadcasts).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
type: "collab_control_changed",
|
type: "collab_control_changed",
|
||||||
controllerUserId: "alice",
|
controllerUserId: "alice",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect((await control("bob", null)).statusCode).toBe(403);
|
expect((await control("bob", null)).statusCode).toBe(403);
|
||||||
expect((await control("alice", null)).statusCode).toBe(200);
|
expect((await control("alice", null)).statusCode).toBe(200);
|
||||||
expect(getStageController(roomId)).toBeNull();
|
expect(await getStageController(roomId)).toBeNull();
|
||||||
|
|
||||||
const asked = await as("bob", () =>
|
const asked = await as("bob", () =>
|
||||||
invoke("post", "/rooms/:id/control/request", { params: { id: roomId } }),
|
invoke("post", "/rooms/:id/control/request", { params: { id: roomId } }),
|
||||||
@@ -551,6 +593,31 @@ describe("collab room routes", () => {
|
|||||||
userId: "bob",
|
userId: "bob",
|
||||||
username: "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 () => {
|
it("guest link: host-only toggle, anonymous resolve follows the stage, rate limited", async () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,13 @@ export interface CollabRoomDetail {
|
|||||||
online: CollabOnlineUser[];
|
online: CollabOnlineUser[];
|
||||||
stage: CollabStage;
|
stage: CollabStage;
|
||||||
controllerUserId: string | null;
|
controllerUserId: string | null;
|
||||||
|
controlRequests: CollabControlRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollabControlRequest {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
requestedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
|
export async function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
|
||||||
@@ -145,14 +152,43 @@ export async function setCollabStageControl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestCollabStageControl(roomId: string): Promise<void> {
|
export async function requestCollabStageControl(
|
||||||
|
roomId: string,
|
||||||
|
): Promise<{ request: CollabControlRequest }> {
|
||||||
try {
|
try {
|
||||||
await authApi.post(`/collab/rooms/${roomId}/control/request`);
|
const response = await authApi.post(
|
||||||
|
`/collab/rooms/${roomId}/control/request`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "request stage control");
|
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<void> {
|
||||||
|
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<void> {
|
export async function endCollabRoom(roomId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authApi.post(`/collab/rooms/${roomId}/end`);
|
await authApi.post(`/collab/rooms/${roomId}/end`);
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
onDismissRequest: (userId: string) => Promise<void>;
|
||||||
|
onRemoveMember: (userId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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<void>) {
|
||||||
|
setBusyUserId(userId);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
setBusyUserId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grant(request: CollabControlRequest) {
|
||||||
|
await run(request.userId, async () => {
|
||||||
|
await onControl(request.userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
id="collab-members-sidebar"
|
||||||
|
aria-label={t("collab.membersPanel")}
|
||||||
|
className="absolute inset-y-0 right-0 z-30 flex h-full w-[min(20rem,100%)] shrink-0 flex-col border-l border-border bg-background shadow-xl lg:static lg:z-auto lg:shadow-none"
|
||||||
|
>
|
||||||
|
<header className="flex h-11 items-center gap-2 border-b border-border px-3">
|
||||||
|
<Users className="size-4 text-muted-foreground" />
|
||||||
|
<h2 className="flex-1 text-sm font-semibold">
|
||||||
|
{t("collab.membersWithCount", { count: detail.members.length })}
|
||||||
|
</h2>
|
||||||
|
{detail.isHost && (
|
||||||
|
<Button size="sm" variant="outline" onClick={onInvite}>
|
||||||
|
{t("collab.invite")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{canManageControl && (
|
||||||
|
<section className="border-b border-border p-3" aria-live="polite">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Hand className="size-3.5 text-muted-foreground" />
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide">
|
||||||
|
{t("collab.controlQueue")}
|
||||||
|
</h3>
|
||||||
|
{detail.controlRequests.length > 0 && (
|
||||||
|
<Badge variant="default" className="ml-auto text-xs">
|
||||||
|
{detail.controlRequests.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{detail.controlRequests.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("collab.noControlRequests")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{detail.controlRequests.map((request) => (
|
||||||
|
<div
|
||||||
|
key={request.userId}
|
||||||
|
className="flex items-center gap-2 border border-border p-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium">
|
||||||
|
{request.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(request.requestedAt).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t("collab.grantControlTo", {
|
||||||
|
name: request.username,
|
||||||
|
})}
|
||||||
|
disabled={busyUserId === request.userId}
|
||||||
|
onClick={() => void grant(request)}
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="outline"
|
||||||
|
aria-label={t("collab.dismissControlRequest", {
|
||||||
|
name: request.username,
|
||||||
|
})}
|
||||||
|
disabled={busyUserId === request.userId}
|
||||||
|
onClick={() =>
|
||||||
|
void run(request.userId, () =>
|
||||||
|
onDismissRequest(request.userId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
<h3 className="sr-only">{t("collab.members")}</h3>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{detail.members.map((member) => {
|
||||||
|
const isOnline = onlineIds.has(member.userId);
|
||||||
|
const isPresenter = member.userId === presenterUserId;
|
||||||
|
const hasControl = member.userId === detail.controllerUserId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.userId}
|
||||||
|
className="group flex min-h-11 items-center gap-2 border border-transparent px-2 py-1.5 hover:border-border hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`size-2 shrink-0 rounded-full ${
|
||||||
|
isOnline ? "bg-green-500" : "bg-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
aria-label={t(isOnline ? "collab.online" : "collab.offline")}
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium">
|
||||||
|
{member.username}
|
||||||
|
{member.userId === detail.me && (
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
{t("collab.you")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{member.roomRole === "host" && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Crown className="size-3" /> {t("collab.hostBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPresenter && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MonitorUp className="size-3" />
|
||||||
|
{t("collab.presenterBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasControl && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MousePointerClick className="size-3" />
|
||||||
|
{t("collab.controlBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canControl && !isPresenter && (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant={hasControl ? "default" : "ghost"}
|
||||||
|
aria-label={t(
|
||||||
|
hasControl
|
||||||
|
? "collab.revokeControl"
|
||||||
|
: "collab.grantControlTo",
|
||||||
|
{ name: member.username },
|
||||||
|
)}
|
||||||
|
disabled={busyUserId === member.userId}
|
||||||
|
onClick={() =>
|
||||||
|
void run(member.userId, () =>
|
||||||
|
onControl(hasControl ? null : member.userId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MousePointerClick className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{detail.isHost && member.userId !== detail.room.ownerUserId && (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t("collab.removeMember", {
|
||||||
|
name: member.username,
|
||||||
|
})}
|
||||||
|
onClick={() =>
|
||||||
|
setRemoving({
|
||||||
|
userId: member.userId,
|
||||||
|
username: member.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UserMinus className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(removing)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setRemoving(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("collab.removeMemberTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("collab.removeMemberDescription", {
|
||||||
|
name: removing?.username,
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => {
|
||||||
|
if (removing) void onRemoveMember(removing.userId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("collab.deleteMember")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,18 +3,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Crown,
|
|
||||||
Hand,
|
Hand,
|
||||||
Link2,
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
MonitorUp,
|
MonitorUp,
|
||||||
MousePointerClick,
|
|
||||||
Presentation,
|
Presentation,
|
||||||
Square,
|
Square,
|
||||||
UserPlus,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Badge } from "@/components/badge";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -33,6 +30,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/alert-dialog";
|
} from "@/components/alert-dialog";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
|
import { CollabMembersSidebar } from "./CollabMembersSidebar";
|
||||||
import { Terminal } from "@/features/terminal/Terminal";
|
import { Terminal } from "@/features/terminal/Terminal";
|
||||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
||||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||||
@@ -45,11 +43,13 @@ import { isElectron } from "@/lib/electron";
|
|||||||
import { getErrorMessage } from "@/lib/error-message";
|
import { getErrorMessage } from "@/lib/error-message";
|
||||||
import {
|
import {
|
||||||
endCollabRoom,
|
endCollabRoom,
|
||||||
|
dismissCollabControlRequest,
|
||||||
getCollabRoom,
|
getCollabRoom,
|
||||||
getCollabStage,
|
getCollabStage,
|
||||||
inviteCollabMembers,
|
inviteCollabMembers,
|
||||||
presentCollabStage,
|
presentCollabStage,
|
||||||
requestCollabStageControl,
|
requestCollabStageControl,
|
||||||
|
removeCollabMember,
|
||||||
setCollabGuestLink,
|
setCollabGuestLink,
|
||||||
setCollabStageControl,
|
setCollabStageControl,
|
||||||
stopCollabStage,
|
stopCollabStage,
|
||||||
@@ -108,6 +108,7 @@ export function CollabRoomTab({
|
|||||||
const [presentOpen, setPresentOpen] = useState(false);
|
const [presentOpen, setPresentOpen] = useState(false);
|
||||||
const [presentLoading, setPresentLoading] = useState(false);
|
const [presentLoading, setPresentLoading] = useState(false);
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
|
const [membersOpen, setMembersOpen] = useState(true);
|
||||||
const [endOpen, setEndOpen] = useState(false);
|
const [endOpen, setEndOpen] = useState(false);
|
||||||
const [takeoverChoice, setTakeoverChoice] = useState<PresentChoice | null>(
|
const [takeoverChoice, setTakeoverChoice] = useState<PresentChoice | null>(
|
||||||
null,
|
null,
|
||||||
@@ -224,19 +225,10 @@ export function CollabRoomTab({
|
|||||||
case "collab_members_changed":
|
case "collab_members_changed":
|
||||||
case "collab_stage_changed":
|
case "collab_stage_changed":
|
||||||
case "collab_control_changed":
|
case "collab_control_changed":
|
||||||
|
case "collab_control_requested":
|
||||||
|
case "collab_control_requests_changed":
|
||||||
void refresh();
|
void refresh();
|
||||||
break;
|
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":
|
case "collab_room_ended":
|
||||||
setEnded(true);
|
setEnded(true);
|
||||||
break;
|
break;
|
||||||
@@ -270,30 +262,49 @@ export function CollabRoomTab({
|
|||||||
const controllerUserId = detail?.controllerUserId ?? null;
|
const controllerUserId = detail?.controllerUserId ?? null;
|
||||||
const presenterUserId = detail?.stage.presenterUserId ?? null;
|
const presenterUserId = detail?.stage.presenterUserId ?? null;
|
||||||
const iAmPresenter = !!me && presenterUserId === me;
|
const iAmPresenter = !!me && presenterUserId === me;
|
||||||
const onlineIds = new Set(detail?.online.map((user) => user.userId));
|
|
||||||
const presenterName = detail?.members.find(
|
const presenterName = detail?.members.find(
|
||||||
(member) => member.userId === presenterUserId,
|
(member) => member.userId === presenterUserId,
|
||||||
)?.username;
|
)?.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) {
|
async function changeControl(targetId: string | null) {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
try {
|
try {
|
||||||
await setCollabStageControl(roomId, targetId);
|
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) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error));
|
toast.error(getErrorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -507,70 +518,14 @@ export function CollabRoomTab({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Header: roster + controls */}
|
{/* Header: room identity + primary controls */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border flex-wrap">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border flex-wrap">
|
||||||
<Presentation className="size-4 text-muted-foreground shrink-0" />
|
<Presentation className="size-4 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm font-semibold truncate">
|
<span className="text-sm font-semibold truncate">
|
||||||
{detail?.room.name}
|
{detail?.room.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 flex-wrap flex-1 min-w-0">
|
<div className="flex-1" />
|
||||||
{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 = (
|
|
||||||
<Badge
|
|
||||||
key={canToggleControl ? undefined : member.userId}
|
|
||||||
variant={hasControl ? "default" : "outline"}
|
|
||||||
className="text-xs gap-1"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`size-1.5 rounded-full ${onlineIds.has(member.userId) ? "bg-green-500" : "bg-muted-foreground/30"}`}
|
|
||||||
/>
|
|
||||||
{member.username}
|
|
||||||
{member.roomRole === "host" && <Crown className="size-2.5" />}
|
|
||||||
{member.userId === presenterUserId && (
|
|
||||||
<MonitorUp className="size-2.5 text-red-500" />
|
|
||||||
)}
|
|
||||||
{hasControl && <MousePointerClick className="size-2.5" />}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
return canToggleControl ? (
|
|
||||||
<button
|
|
||||||
key={member.userId}
|
|
||||||
type="button"
|
|
||||||
title={t(
|
|
||||||
hasControl ? "collab.revokeControl" : "collab.grantControl",
|
|
||||||
)}
|
|
||||||
aria-label={`${member.username}: ${t(
|
|
||||||
hasControl ? "collab.revokeControl" : "collab.grantControl",
|
|
||||||
)}`}
|
|
||||||
onClick={() =>
|
|
||||||
void changeControl(hasControl ? null : member.userId)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
badge
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{isHost && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={() => void openInviteDialog()}
|
|
||||||
>
|
|
||||||
<UserPlus className="size-3.5 mr-1" />
|
|
||||||
{t("collab.invite")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!!detail?.stage.shareId &&
|
{!!detail?.stage.shareId &&
|
||||||
detail.stage.protocol === "ssh" &&
|
detail.stage.protocol === "ssh" &&
|
||||||
!iAmPresenter &&
|
!iAmPresenter &&
|
||||||
@@ -582,14 +537,16 @@ export function CollabRoomTab({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
controllerUserId === me
|
controllerUserId === me
|
||||||
? void changeControl(null)
|
? void changeControl(null)
|
||||||
: void requestCollabStageControl(roomId).catch((error) =>
|
: void toggleOwnControlRequest()
|
||||||
toast.error(getErrorMessage(error)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Hand className="size-3.5 mr-1" />
|
<Hand className="size-3.5 mr-1" />
|
||||||
{controllerUserId === me
|
{controllerUserId === me
|
||||||
? t("collab.releaseControl")
|
? t("collab.releaseControl")
|
||||||
|
: detail.controlRequests.some(
|
||||||
|
(request) => request.userId === me,
|
||||||
|
)
|
||||||
|
? t("collab.cancelControlRequest")
|
||||||
: t("collab.requestControl")}
|
: t("collab.requestControl")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -604,6 +561,17 @@ export function CollabRoomTab({
|
|||||||
{t("collab.stopPresenting")}
|
{t("collab.stopPresenting")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={membersOpen ? "default" : "outline"}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
aria-controls="collab-members-sidebar"
|
||||||
|
aria-expanded={membersOpen}
|
||||||
|
onClick={() => setMembersOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<Users className="mr-1 size-3.5" />
|
||||||
|
{detail?.members.length ?? 0}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
@@ -676,8 +644,9 @@ export function CollabRoomTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex flex-1 min-h-0">
|
||||||
{/* Stage */}
|
{/* Stage */}
|
||||||
<div className="relative flex-1 min-h-0">
|
<div className="relative flex-1 min-w-0 min-h-0">
|
||||||
{draft ? (
|
{draft ? (
|
||||||
draft.protocol === "ssh" ? (
|
draft.protocol === "ssh" ? (
|
||||||
<CommandHistoryProvider>
|
<CommandHistoryProvider>
|
||||||
@@ -763,6 +732,17 @@ export function CollabRoomTab({
|
|||||||
<CenteredNote text={t("collab.emptyStage")} />
|
<CenteredNote text={t("collab.emptyStage")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{membersOpen && detail && (
|
||||||
|
<CollabMembersSidebar
|
||||||
|
detail={detail}
|
||||||
|
onClose={() => setMembersOpen(false)}
|
||||||
|
onInvite={() => void openInviteDialog()}
|
||||||
|
onControl={changeControl}
|
||||||
|
onDismissRequest={dismissControlRequest}
|
||||||
|
onRemoveMember={removeMember}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Present dialog */}
|
{/* Present dialog */}
|
||||||
<Dialog open={presentOpen} onOpenChange={setPresentOpen}>
|
<Dialog open={presentOpen} onOpenChange={setPresentOpen}>
|
||||||
|
|||||||
@@ -1817,10 +1817,17 @@
|
|||||||
"endRoom": "End meeting",
|
"endRoom": "End meeting",
|
||||||
"leaveRoom": "Leave",
|
"leaveRoom": "Leave",
|
||||||
"deleteMember": "Remove",
|
"deleteMember": "Remove",
|
||||||
|
"removeMember": "Remove {{name}}",
|
||||||
|
"removeMemberTitle": "Remove this member?",
|
||||||
|
"removeMemberDescription": "{{name}} will lose access to this room immediately. Any active SSH viewer will be disconnected.",
|
||||||
"invite": "Invite",
|
"invite": "Invite",
|
||||||
"inviteTitle": "Invite members",
|
"inviteTitle": "Invite members",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
|
"membersPanel": "Room members and control requests",
|
||||||
|
"membersWithCount": "Members ({{count}})",
|
||||||
"online": "online",
|
"online": "online",
|
||||||
|
"offline": "offline",
|
||||||
|
"you": "(you)",
|
||||||
"hostBadge": "Host",
|
"hostBadge": "Host",
|
||||||
"presenterBadge": "Presenting",
|
"presenterBadge": "Presenting",
|
||||||
"present": "Present",
|
"present": "Present",
|
||||||
@@ -1863,6 +1870,11 @@
|
|||||||
},
|
},
|
||||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||||
"requestControl": "Request control",
|
"requestControl": "Request control",
|
||||||
|
"cancelControlRequest": "Cancel request",
|
||||||
|
"controlQueue": "Control requests",
|
||||||
|
"noControlRequests": "No pending requests.",
|
||||||
|
"grantControlTo": "Give control to {{name}}",
|
||||||
|
"dismissControlRequest": "Dismiss {{name}}'s control request",
|
||||||
"releaseControl": "Release control",
|
"releaseControl": "Release control",
|
||||||
"grantControl": "Give control",
|
"grantControl": "Give control",
|
||||||
"revokeControl": "Take back control",
|
"revokeControl": "Take back control",
|
||||||
|
|||||||
Reference in New Issue
Block a user