import { useCallback, useEffect, useRef, useState } from "react"; import { hostProtocols, useHosts, useTranslation, type PluginHostRecord, } from "@termix/plugin-sdk/frontend"; import { toast } from "sonner"; import { AlertCircle, Hand, Link2, Loader2, MonitorUp, Presentation, Square, Users, } from "lucide-react"; import { Button } from "@termix/plugin-sdk/ui"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@termix/plugin-sdk/ui"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@termix/plugin-sdk/ui"; import { Input, PluginComponent, resolveConnectionOrigin, } from "@termix/plugin-sdk/ui"; import { CollabMembersSidebar } from "./CollabMembersSidebar"; import { RemoteDisplay, createRemoteSessionToken, getErrorMessage, wsUrlForPath, } from "./shared"; import { deleteCollabRoom, endCollabRoom, dismissCollabControlRequest, getCollabRoom, getCollabStage, inviteCollabMembers, presentCollabStage, requestCollabStageControl, removeCollabMember, setCollabGuestLink, setCollabStageControl, stopCollabStage, getDirectory, type CollabRoomDetail, type CollabStage, type DirectoryRole, } from "./api"; type SharedProtocol = "ssh" | "rdp" | "vnc" | "telnet"; function isSharedProtocol(value: string): value is SharedProtocol { return ( value === "ssh" || value === "rdp" || value === "vnc" || value === "telnet" ); } /** A host from the shell's list; the connection fields ride along by name. */ type SSHHostWithStatus = PluginHostRecord & { enableSsh?: boolean; connectionOrigin?: unknown; }; const PING_INTERVAL_MS = 30000; const POLL_FALLBACK_MS = 15000; // The terminal socket the ssh-terminal plugin serves. Authentication rides on // the jwt cookie the way every terminal WS connection does. async function roomEventsWsUrl(roomId: string): Promise { const { eventsWsPath } = await getCollabRoom(roomId); return wsUrlForPath(eventsWsPath); } type PresentDraft = | { protocol: "ssh"; host: SSHHostWithStatus } | { protocol: "rdp" | "vnc" | "telnet"; host: SSHHostWithStatus; token: string; guacamoleConnectionId: string; }; type PresentChoice = { host: SSHHostWithStatus; protocol: "ssh" | "rdp" | "vnc" | "telnet"; }; export function CollabRoomTab({ roomId, isVisible, }: { roomId?: string; isVisible: boolean; }) { const { t } = useTranslation(); const [detail, setDetail] = useState(null); const [stage, setStage] = useState(null); const [ended, setEnded] = useState(false); const [loadError, setLoadError] = useState(null); const [draft, setDraft] = useState(null); const [presentOpen, setPresentOpen] = useState(false); const { loaded: hostsLoaded } = useHosts(); const presentLoading = !hostsLoaded; const [inviteOpen, setInviteOpen] = useState(false); const [membersOpen, setMembersOpen] = useState(true); const [endOpen, setEndOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); const [takeoverChoice, setTakeoverChoice] = useState( null, ); const [guestLinkToken, setGuestLinkToken] = useState(null); const [guestLinkAction, setGuestLinkAction] = useState< "disable" | "rotate" | null >(null); const [hostSearch, setHostSearch] = useState(""); const [inviteSearch, setInviteSearch] = useState(""); const { hosts: shellHosts } = useHosts(); const hosts = shellHosts as SSHHostWithStatus[]; const [users, setUsers] = useState>( [], ); const [inviteSelection, setInviteSelection] = useState>( new Set(), ); const [roles, setRoles] = useState([]); const [roleSelection, setRoleSelection] = useState>(new Set()); const draftRef = useRef(null); draftRef.current = draft; const stageKeyRef = useRef(null); const refreshSequence = useRef(0); const refresh = useCallback(async () => { if (!roomId) return; const sequence = ++refreshSequence.current; try { const nextDetail = await getCollabRoom(roomId); if (sequence !== refreshSequence.current) return; setDetail(nextDetail); setLoadError(null); // Presenting locally? The local session is the stage - don't join it. if ( nextDetail.stage.shareId && nextDetail.stage.presenterUserId !== nextDetail.me ) { // A guac viewer reconnects whenever its token changes, so the stage // is only re-resolved when the share or my control actually changed. const stageKey = `${nextDetail.stage.shareId}:${ nextDetail.controllerUserId === nextDetail.me }`; if (stageKeyRef.current !== stageKey) { stageKeyRef.current = stageKey; const { stage: resolved } = await getCollabStage(roomId); setStage(resolved); } } else if (!nextDetail.stage.shareId) { stageKeyRef.current = null; setStage(null); // The stage was cleared elsewhere; stop presenting locally too. if (draftRef.current) setDraft(null); } } catch (error) { if (sequence === refreshSequence.current) { setLoadError(getErrorMessage(error)); } } }, [roomId]); useEffect(() => { void refresh(); }, [refresh]); // Live room events. Poll only while the socket is unavailable. useEffect(() => { if (!roomId) return; let ws: WebSocket | null = null; let pingTimer: ReturnType | null = null; let pollTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempt = 0; let cancelled = false; const startPolling = () => { pollTimer ??= setInterval(() => void refresh(), POLL_FALLBACK_MS); }; const stopPolling = () => { if (pollTimer) clearInterval(pollTimer); pollTimer = null; }; const connect = async () => { if (cancelled) return; try { const url = await roomEventsWsUrl(roomId); if (cancelled) return; if (!url) throw new Error("No terminal endpoint is available"); ws = new WebSocket(url); } catch { startPolling(); reconnectTimer = setTimeout(() => void connect(), 5000); return; } ws.onopen = () => { reconnectAttempt = 0; stopPolling(); ws?.send( JSON.stringify({ type: "collab_subscribe", data: { roomId } }), ); pingTimer = setInterval(() => { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping" })); } }, PING_INTERVAL_MS); }; ws.onmessage = (event) => { if (cancelled) return; let msg: { type?: string; roomId?: string }; try { msg = JSON.parse(event.data); } catch { return; } if (msg.roomId !== roomId) return; switch (msg.type) { case "collab_online": 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_room_ended": setEnded(true); break; default: break; } }; ws.onclose = () => { if (cancelled) return; if (pingTimer) clearInterval(pingTimer); pingTimer = null; startPolling(); const delay = Math.min(1000 * 2 ** reconnectAttempt++, 15000); reconnectTimer = setTimeout(() => void connect(), delay); }; ws.onerror = () => ws?.close(); }; void connect(); return () => { cancelled = true; if (pingTimer) clearInterval(pingTimer); if (pollTimer) clearInterval(pollTimer); if (reconnectTimer) clearTimeout(reconnectTimer); ws?.close(); }; }, [roomId, refresh]); const me = detail?.me; const isHost = detail?.isHost ?? false; const controllerUserId = detail?.controllerUserId ?? null; const presenterUserId = detail?.stage.presenterUserId ?? null; const iAmPresenter = !!me && presenterUserId === me; const presenterName = detail?.members.find( (member) => member.userId === presenterUserId, )?.username; 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)); } } function openPresentDialog() { setPresentOpen(true); } function choosePresent( host: SSHHostWithStatus, protocol: "ssh" | "rdp" | "vnc" | "telnet", ) { if (presenterUserId && !iAmPresenter) { setPresentOpen(false); setTakeoverChoice({ host, protocol }); return; } void startPresent(host, protocol); } async function startPresent( host: SSHHostWithStatus, protocol: "ssh" | "rdp" | "vnc" | "telnet", ) { if (!roomId) return; setPresentOpen(false); try { if (protocol === "ssh") { setDraft({ protocol, host }); return; } const response = await createRemoteSessionToken( Number(host.id), await resolveConnectionOrigin( { connectionOrigin: host.connectionOrigin as "local" | "remote" | undefined, }, { defaultRemote: true }, ), protocol, ); if (!response?.connectionId) { toast.error(t("collab.stageLoading")); return; } setDraft({ protocol, host, token: response.token, guacamoleConnectionId: response.connectionId, }); } catch (error) { toast.error(getErrorMessage(error)); } } async function registerStage( protocol: string, sessionId: string, hostId: number, ) { if (!roomId) return; try { await presentCollabStage(roomId, { protocol, sessionId, hostId }); void refresh(); } catch (error) { toast.error(getErrorMessage(error)); setDraft(null); } } async function handleStop() { if (!roomId) return; try { await stopCollabStage(roomId); setDraft(null); void refresh(); } catch (error) { toast.error(getErrorMessage(error)); } } async function handleEnd() { if (!roomId) return; try { await endCollabRoom(roomId); setDraft(null); if (!detail?.room.persistent) setEnded(true); void refresh(); } catch (error) { toast.error(getErrorMessage(error)); } } async function handleDelete() { if (!roomId) return; setDeleting(true); try { await deleteCollabRoom(roomId); setDraft(null); setEnded(true); } catch (error) { toast.error(getErrorMessage(error)); } finally { setDeleting(false); } } async function openInviteDialog() { setInviteOpen(true); setInviteSelection(new Set()); setRoleSelection(new Set()); try { const directory = await getDirectory(); setUsers(directory.users); setRoles(directory.roles); } catch (error) { toast.error(getErrorMessage(error)); } } async function handleGuestLink(enabled: boolean) { if (!roomId) return; try { const result = await setCollabGuestLink(roomId, enabled); setGuestLinkToken(result.guestLinkToken); await refresh(); } catch (error) { toast.error(getErrorMessage(error)); } } function guestLinkUrl(token: string) { return `${window.location.origin}${window.location.pathname}?view=collab-guest&token=${token}`; } async function handleInvite() { if (!roomId || (inviteSelection.size === 0 && roleSelection.size === 0)) return; try { await inviteCollabMembers(roomId, { userIds: Array.from(inviteSelection), roleIds: Array.from(roleSelection), }); toast.success(t("collab.invited")); setInviteOpen(false); void refresh(); } catch (error) { toast.error(getErrorMessage(error)); } } if (!roomId) { return (

{t("collab.reopenFromPanel")}

); } if (ended) { return (

{t("collab.roomEnded")}

); } if (!detail && loadError) { return (

{loadError}

); } const memberIds = new Set(detail?.members.map((member) => member.userId)); const normalizedInviteSearch = inviteSearch.trim().toLocaleLowerCase(); const invitableUsers = users.filter( (user) => !memberIds.has(user.id) && user.username.toLocaleLowerCase().includes(normalizedInviteSearch), ); const normalizedHostSearch = hostSearch.trim().toLocaleLowerCase(); const filteredHosts = hosts.filter((host) => host.name.toLocaleLowerCase().includes(normalizedHostSearch), ); return (
{loadError && (
{loadError}
)} {/* Header: room identity + primary controls */}
{detail?.room.name}
{!!detail?.stage.shareId && detail.stage.protocol === "ssh" && !iAmPresenter && !draft && ( )} {(iAmPresenter || draft || (isHost && presenterUserId)) && ( )} {isHost && ( )} {isHost && detail?.room.persistent && ( )}
{isHost && (
{detail?.room.guestLinkEnabled ? t("collab.guestLinkOn") : t("collab.guestLinkOff")} {guestLinkToken && ( )} {detail?.room.guestLinkEnabled && !guestLinkToken && ( )}
)}
{/* Stage */}
{draft ? ( draft.protocol === "ssh" ? ( void registerStage("ssh", sessionId, Number(draft.host.id)) } /> ) : ( void registerStage( draft.protocol, draft.guacamoleConnectionId, Number(draft.host.id), ) } onError={(err) => { toast.error(err); setDraft(null); }} /> ) ) : stage && stage.protocol && !iAmPresenter ? ( <> {presenterName && (
{t("collab.presenterLabel", { name: presenterName })}
)} {stage.protocol === "ssh" ? ( ) : stage.connectParams?.token ? ( ) : ( )} ) : iAmPresenter && !draft ? ( ) : ( )}
{membersOpen && detail && ( setMembersOpen(false)} onInvite={() => void openInviteDialog()} onControl={changeControl} onDismissRequest={dismissControlRequest} onRemoveMember={removeMember} /> )}
{/* Present dialog */} {t("collab.presentTitle")} setHostSearch(event.target.value)} />
{presentLoading && (
)} {!presentLoading && filteredHosts.length === 0 && ( )} {filteredHosts.map((host) => { const protocols = hostProtocols( host as unknown as PluginHostRecord, ).filter(isSharedProtocol); if (protocols.length === 0) return null; return (
{host.name} {protocols.map((protocol) => ( ))}
); })}
{/* Invite dialog */} {t("collab.inviteTitle")} setInviteSearch(event.target.value)} />
{roles.length > 0 && ( {t("collab.roles")} )} {roles.map((role) => ( ))} {t("collab.users")} {invitableUsers.map((user) => ( ))}
{t("collab.endConfirmTitle")} {t( detail?.room.persistent ? "collab.endPersistentDescription" : "collab.endDescription", { name: detail?.room.name }, )} {t("common.cancel")} void handleEnd()} > {t("collab.endRoom")} {t("collab.deleteConfirmTitle")} {t("collab.deleteDescription", { name: detail?.room.name })} {t("common.cancel")} void handleDelete()} > {t("collab.deleteRoom")} !open && setTakeoverChoice(null)} > {t("collab.takeOverConfirmTitle")} {t("collab.takeOverDescription", { name: presenterName })} {t("common.cancel")} { const choice = takeoverChoice; setTakeoverChoice(null); if (choice) void startPresent(choice.host, choice.protocol); }} > {t("collab.takeOver")} !open && setGuestLinkAction(null)} > {t( guestLinkAction === "rotate" ? "collab.rotateLinkConfirmTitle" : "collab.disableLinkConfirmTitle", )} {t( guestLinkAction === "rotate" ? "collab.rotateLinkDescription" : "collab.disableLinkDescription", )} {t("common.cancel")} { const enabled = guestLinkAction === "rotate"; setGuestLinkAction(null); void handleGuestLink(enabled); }} > {t( guestLinkAction === "rotate" ? "collab.rotateLink" : "collab.disableLink", )}
); } function CenteredNote({ text }: { text: string }) { return (

{text}

); }