feat: collaboration rooms with switchable presenter (#1328)

* feat: add collaboration rooms with switchable presenter

Rooms are a group of members watching one stage - the live SSH/RDP/VNC
session the current presenter shares. Any member can take over the
stage; the host can invite, force-stop and end the meeting. Stages
reuse session_shares (new room share type), so gating, recording,
expiry and the global sharing toggle all apply unchanged.

* feat: add stage control handoff to collaboration rooms

The presenter or host can grant any member write access to the live
stage and take it back; members can raise a hand to ask. SSH flips the
participant's permission on the live gate; RDP/VNC re-mint the viewer's
join token. Control clears on every stage switch.

* feat: guest links, role invites and invite awareness for collab rooms

- Anonymous guest link per room (host toggles/rotates), followed by
  polling the public resolve endpoint; SSH guests join over the terminal
  WS with roomGuestToken, guac guests get read-only join tokens
- Invite by role (expands to current members, snapshot semantics)
- Toast when a room you were invited to appears
- Stale stages are cleared lazily when the presenter is gone
- Telnet presenting, expired-tab fallback, documented single-instance
  and guac-kick limits
- Tests for the collab routes, room hub, share access and control flip

* fix: keep remote desktop collaboration read-only
This commit is contained in:
ZacharyZcR
2026-08-25 00:56:04 +08:00
committed by GitHub
parent d35458f78b
commit 81d79cc89b
50 changed files with 56698 additions and 81 deletions
+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Badge } from "@/components/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
createCollabRoom,
listCollabRooms,
type CollabRoom,
} from "@/api/collab-api";
import { getErrorMessage } from "@/lib/error-message";
export function CollabPanel({
onOpenRoom,
}: {
onOpenRoom: (room: CollabRoom) => void;
}) {
const { t } = useTranslation();
const [rooms, setRooms] = useState<CollabRoom[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [persistent, setPersistent] = useState(false);
const [creating, setCreating] = useState(false);
const refresh = useCallback(async () => {
try {
const result = await listCollabRooms();
setRooms(result.rooms);
} catch {
/* the list stays as-is */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleCreate() {
if (!name.trim()) return;
setCreating(true);
try {
const { room } = await createCollabRoom(name.trim(), persistent);
toast.success(t("collab.created"));
setCreateOpen(false);
setName("");
setPersistent(false);
await refresh();
onOpenRoom(room);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreating(false);
}
}
return (
<div className="flex flex-col gap-2 p-2">
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-7 text-xs flex-1"
onClick={() => setCreateOpen(true)}
>
<Plus className="size-3.5 mr-1" />
{t("collab.createRoom")}
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-2"
onClick={() => void refresh()}
>
<RefreshCw className="size-3.5" />
</Button>
</div>
{loading ? (
<div className="flex justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : rooms.length === 0 ? (
<p className="text-xs text-muted-foreground px-1 py-4 text-center">
{t("collab.noRooms")}
</p>
) : (
<div className="flex flex-col gap-1">
{rooms.map((room) => (
<button
key={room.id}
type="button"
onClick={() => onOpenRoom(room)}
className="flex items-center gap-2 px-2 py-1.5 text-left border border-border hover:bg-muted/50"
>
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 text-xs truncate">{room.name}</span>
{room.presenterUserId && (
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
)}
{room.persistent && (
<Badge variant="outline" className="text-[9px] px-1 py-0">
{t("collab.persistentRoom")}
</Badge>
)}
</button>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder={t("collab.roomName")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
/>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={persistent}
onChange={(e) => setPersistent(e.target.checked)}
/>
<span>
<span className="font-medium">
{t("collab.persistentRoom")}
</span>
<br />
<span className="text-muted-foreground">
{t("collab.persistentRoomHint")}
</span>
</span>
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCreateOpen(false)}
disabled={creating}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => void handleCreate()}
disabled={creating || !name.trim()}
>
{creating && <Loader2 className="size-3.5 mr-1 animate-spin" />}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}