feat: improve collaboration rooms (#1338)

This commit is contained in:
ZacharyZcR
2026-08-25 02:15:44 +08:00
committed by GitHub
parent 8d0bcb3b1f
commit 0ab7cf2ab8
17 changed files with 1260 additions and 222 deletions
+44 -8
View File
@@ -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<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 {
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<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);
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<string, string>();
@@ -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<void> {
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),
});
}
}
+166 -22
View File
@@ -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<string, number>();
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<void> {
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<string, unknown> = {
...stagePayload(room),
sessionId: share.sessionId,
@@ -632,7 +648,7 @@ async function applyStageControl(
roomId: string,
controllerUserId: string | null,
): Promise<void> {
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);
+286
View File
@@ -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();
+7 -9
View File
@@ -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<string, string>();
import { collabRuntimeStore } from "./runtime-store.js";
export function getStageController(roomId: string): string | null {
return controllers.get(roomId) ?? null;
export function getStageController(roomId: string): Promise<string | null> {
return collabRuntimeStore.getController(roomId);
}
export function setStageController(
roomId: string,
userId: string | null,
): void {
if (userId) controllers.set(roomId, userId);
else controllers.delete(roomId);
): Promise<void> {
return collabRuntimeStore.setController(roomId, userId);
}
@@ -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<typeof vi.fn>).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<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);
});
@@ -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<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);
});
});
+74 -7
View File
@@ -29,6 +29,11 @@ const state = vi.hoisted(() => ({
liveOwned: new Map<string, string>(), // sessionId -> owner
broadcasts: [] as Array<Record<string, 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", () => ({
@@ -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 () => {
@@ -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();
});
});
+38 -2
View File
@@ -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<void> {
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<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> {
try {
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>
);
}
+146 -166
View File
@@ -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<PresentChoice | null>(
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({
</Button>
</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">
<Presentation className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-semibold truncate">
{detail?.room.name}
</span>
<div className="flex items-center gap-1 flex-wrap flex-1 min-w-0">
{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-1" />
<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.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()
}
>
<Hand className="size-3.5 mr-1" />
{controllerUserId === me
? t("collab.releaseControl")
: t("collab.requestControl")}
: detail.controlRequests.some(
(request) => request.userId === me,
)
? t("collab.cancelControlRequest")
: t("collab.requestControl")}
</Button>
)}
{(iAmPresenter || draft || (isHost && presenterUserId)) && (
@@ -604,6 +561,17 @@ export function CollabRoomTab({
{t("collab.stopPresenting")}
</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
size="sm"
className="h-8 text-xs"
@@ -676,91 +644,103 @@ export function CollabRoomTab({
</div>
)}
{/* Stage */}
<div className="relative flex-1 min-h-0">
{draft ? (
draft.protocol === "ssh" ? (
<CommandHistoryProvider>
<Terminal
hostConfig={{
...draft.host,
id: Number(draft.host.id),
ip: draft.host.ip,
port: draft.host.port,
username: draft.host.username,
instanceId: `collab-present-${roomId}`,
}}
isVisible={isVisible}
disableAutoFocus={false}
onSessionReady={(sessionId) =>
void registerStage("ssh", sessionId, Number(draft.host.id))
}
/>
</CommandHistoryProvider>
) : (
<GuacamoleDisplay
connectionConfig={{
token: draft.token,
protocol: draft.protocol,
type: draft.protocol,
}}
isVisible={isVisible}
onConnect={() =>
void registerStage(
draft.protocol,
draft.guacamoleConnectionId,
Number(draft.host.id),
)
}
onError={(err) => {
toast.error(err);
setDraft(null);
}}
/>
)
) : stage && stage.protocol && !iAmPresenter ? (
<>
{presenterName && (
<div className="absolute top-2 left-2 z-20 rounded px-2 py-0.5 text-[10px] bg-background/80 border border-border">
{t("collab.presenterLabel", { name: presenterName })}
</div>
)}
{stage.protocol === "ssh" ? (
<div className="relative flex flex-1 min-h-0">
{/* Stage */}
<div className="relative flex-1 min-w-0 min-h-0">
{draft ? (
draft.protocol === "ssh" ? (
<CommandHistoryProvider>
<Terminal
hostConfig={{
id: stage.hostId ?? undefined,
name: detail?.room.name ?? "stage",
ip: "",
port: 0,
username: "",
authType: "none",
instanceId: `collab-view-${roomId}-${stage.shareId}`,
joinShareId: stage.shareId,
joinSharedSessionId: stage.sessionId ?? null,
...draft.host,
id: Number(draft.host.id),
ip: draft.host.ip,
port: draft.host.port,
username: draft.host.username,
instanceId: `collab-present-${roomId}`,
}}
isVisible={isVisible}
disableAutoFocus
disableAutoFocus={false}
onSessionReady={(sessionId) =>
void registerStage("ssh", sessionId, Number(draft.host.id))
}
/>
</CommandHistoryProvider>
) : stage.connectParams?.token ? (
) : (
<GuacamoleDisplay
key={stage.connectParams.token}
connectionConfig={{
token: stage.connectParams.token,
protocol: stage.protocol,
type: stage.protocol,
token: draft.token,
protocol: draft.protocol,
type: draft.protocol,
}}
isVisible={isVisible}
onConnect={() =>
void registerStage(
draft.protocol,
draft.guacamoleConnectionId,
Number(draft.host.id),
)
}
onError={(err) => {
toast.error(err);
setDraft(null);
}}
/>
) : (
<CenteredNote text={t("collab.stageLoading")} />
)}
</>
) : iAmPresenter && !draft ? (
<CenteredNote text={t("collab.youArePresenting")} />
) : (
<CenteredNote text={t("collab.emptyStage")} />
)
) : stage && stage.protocol && !iAmPresenter ? (
<>
{presenterName && (
<div className="absolute top-2 left-2 z-20 rounded px-2 py-0.5 text-[10px] bg-background/80 border border-border">
{t("collab.presenterLabel", { name: presenterName })}
</div>
)}
{stage.protocol === "ssh" ? (
<CommandHistoryProvider>
<Terminal
hostConfig={{
id: stage.hostId ?? undefined,
name: detail?.room.name ?? "stage",
ip: "",
port: 0,
username: "",
authType: "none",
instanceId: `collab-view-${roomId}-${stage.shareId}`,
joinShareId: stage.shareId,
joinSharedSessionId: stage.sessionId ?? null,
}}
isVisible={isVisible}
disableAutoFocus
/>
</CommandHistoryProvider>
) : stage.connectParams?.token ? (
<GuacamoleDisplay
key={stage.connectParams.token}
connectionConfig={{
token: stage.connectParams.token,
protocol: stage.protocol,
type: stage.protocol,
}}
isVisible={isVisible}
/>
) : (
<CenteredNote text={t("collab.stageLoading")} />
)}
</>
) : iAmPresenter && !draft ? (
<CenteredNote text={t("collab.youArePresenting")} />
) : (
<CenteredNote text={t("collab.emptyStage")} />
)}
</div>
{membersOpen && detail && (
<CollabMembersSidebar
detail={detail}
onClose={() => setMembersOpen(false)}
onInvite={() => void openInviteDialog()}
onControl={changeControl}
onDismissRequest={dismissControlRequest}
onRemoveMember={removeMember}
/>
)}
</div>
+12
View File
@@ -1817,10 +1817,17 @@
"endRoom": "End meeting",
"leaveRoom": "Leave",
"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",
"inviteTitle": "Invite members",
"members": "Members",
"membersPanel": "Room members and control requests",
"membersWithCount": "Members ({{count}})",
"online": "online",
"offline": "offline",
"you": "(you)",
"hostBadge": "Host",
"presenterBadge": "Presenting",
"present": "Present",
@@ -1863,6 +1870,11 @@
},
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
"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",
"grantControl": "Give control",
"revokeControl": "Take back control",