mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
fix(session-sharing): show participant presence in member terminals (#1504)
This commit is contained in:
1 parent
0f6e48a95c
commit
4ed9b69aab
6 files changed
+114
-5
No files matched your search
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
|
import {
|
||||||
|
ParticipantsBadge,
|
||||||
|
type SessionParticipantInfo,
|
||||||
|
} from "./SharedSessionView";
|
||||||
|
|
||||||
|
interface ParticipantsOverlayProps {
|
||||||
|
subscribe: (
|
||||||
|
listener: (message: { type: string; [key: string]: unknown }) => void,
|
||||||
|
) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParticipantsOverlay({ subscribe }: ParticipantsOverlayProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribe((message) => {
|
||||||
|
if (
|
||||||
|
message.type === "participants" &&
|
||||||
|
Array.isArray(message.participants)
|
||||||
|
) {
|
||||||
|
setParticipants(message.participants as SessionParticipantInfo[]);
|
||||||
|
} else if (message.type === "session_closed") {
|
||||||
|
setParticipants([]);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[subscribe],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParticipantsBadge
|
||||||
|
participants={participants}
|
||||||
|
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export interface SessionParticipantInfo {
|
|||||||
label: string | null;
|
label: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParticipantsBadge({
|
export function ParticipantsBadge({
|
||||||
participants,
|
participants,
|
||||||
ownerLabel,
|
ownerLabel,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import CollabGuestView from "./CollabGuestView";
|
|||||||
import { CollabPanel } from "./CollabPanel";
|
import { CollabPanel } from "./CollabPanel";
|
||||||
import { CollabRoomTab } from "./CollabRoomTab";
|
import { CollabRoomTab } from "./CollabRoomTab";
|
||||||
import SharedSessionView from "./SharedSessionView";
|
import SharedSessionView from "./SharedSessionView";
|
||||||
|
import { ParticipantsOverlay } from "./ParticipantsOverlay";
|
||||||
import {
|
import {
|
||||||
ShareDialogHost,
|
ShareDialogHost,
|
||||||
closeShareDialog,
|
closeShareDialog,
|
||||||
@@ -224,6 +225,15 @@ export function activate(app: TermixApp): void {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.registerSlotContribution("terminal.overlay", {
|
||||||
|
actionId: "session-sharing.participants",
|
||||||
|
titleKey: "collab.members",
|
||||||
|
kind: "component",
|
||||||
|
component: ParticipantsOverlay as unknown as ComponentType<
|
||||||
|
Record<string, unknown>
|
||||||
|
>,
|
||||||
|
});
|
||||||
|
|
||||||
app.registerSlotContribution("shell.overlay", {
|
app.registerSlotContribution("shell.overlay", {
|
||||||
actionId: "session-sharing.shareDialog",
|
actionId: "session-sharing.shareDialog",
|
||||||
titleKey: "sessionSharing.shareButton",
|
titleKey: "sessionSharing.shareButton",
|
||||||
|
|||||||
@@ -75,6 +75,52 @@ describe("session-sharing activate", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows terminal participants and clears them after the last guest leaves or the socket closes", async () => {
|
||||||
|
rendered = await renderWithApp(plugin, {
|
||||||
|
manifest,
|
||||||
|
locales,
|
||||||
|
api: stubApi(),
|
||||||
|
});
|
||||||
|
expect(rendered.registered.slot("terminal.overlay")).toEqual([
|
||||||
|
"session-sharing.participants",
|
||||||
|
]);
|
||||||
|
let receive: (message: {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => void = () => {};
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
rendered.renderSlot("terminal.overlay", {
|
||||||
|
subscribe: (listener: typeof receive) => {
|
||||||
|
receive = listener;
|
||||||
|
return unsubscribe;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const owner = { isOwner: true, permissionLevel: "read-write", label: null };
|
||||||
|
const guest = {
|
||||||
|
isOwner: false,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
label: "Guest 1",
|
||||||
|
};
|
||||||
|
await act(() =>
|
||||||
|
receive({ type: "participants", participants: [owner, guest] }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByTitle(
|
||||||
|
`${locales.sessionSharing.guestView.ownerLabel}, Guest 1`,
|
||||||
|
).textContent,
|
||||||
|
).toBe("2");
|
||||||
|
await act(() => receive({ type: "participants", participants: [owner] }));
|
||||||
|
expect(screen.queryByText("2")).toBeNull();
|
||||||
|
await act(() =>
|
||||||
|
receive({ type: "participants", participants: [owner, guest] }),
|
||||||
|
);
|
||||||
|
await act(() => receive({ type: "session_closed" }));
|
||||||
|
expect(screen.queryByText("2")).toBeNull();
|
||||||
|
await rendered.deactivate();
|
||||||
|
expect(unsubscribe).toHaveBeenCalled();
|
||||||
|
rendered = null;
|
||||||
|
});
|
||||||
|
|
||||||
it("registers only the guest views on a guest page", async () => {
|
it("registers only the guest views on a guest page", async () => {
|
||||||
rendered = await renderWithApp(plugin, {
|
rendered = await renderWithApp(plugin, {
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ export class TerminalSessionManager {
|
|||||||
* leave, and only while someone besides the owner is (or just was) in the
|
* leave, and only while someone besides the owner is (or just was) in the
|
||||||
* room - a solo owner never receives presence traffic.
|
* room - a solo owner never receives presence traffic.
|
||||||
*/
|
*/
|
||||||
private broadcastParticipants(sessionId: string): void {
|
private broadcastParticipants(sessionId: string, includeSolo = false): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const participants = Array.from(session.participants.values()).map(
|
const participants = Array.from(session.participants.values()).map(
|
||||||
@@ -481,7 +481,11 @@ export class TerminalSessionManager {
|
|||||||
label: participant.displayName ?? participant.guestLabel ?? null,
|
label: participant.displayName ?? participant.guestLabel ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (participants.every((participant) => participant.isOwner)) return;
|
if (
|
||||||
|
!includeSolo &&
|
||||||
|
participants.every((participant) => participant.isOwner)
|
||||||
|
)
|
||||||
|
return;
|
||||||
this.broadcast(sessionId, { type: "participants", participants });
|
this.broadcast(sessionId, { type: "participants", participants });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +549,7 @@ export class TerminalSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (disconnected > 0) this.broadcastParticipants(sessionId);
|
if (disconnected > 0) this.broadcastParticipants(sessionId, true);
|
||||||
return disconnected;
|
return disconnected;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +589,7 @@ export class TerminalSessionManager {
|
|||||||
for (const [id, participant] of session.participants.entries()) {
|
for (const [id, participant] of session.participants.entries()) {
|
||||||
if (participant.ws === ws && !participant.isOwner) {
|
if (participant.ws === ws && !participant.isOwner) {
|
||||||
session.participants.delete(id);
|
session.participants.delete(id);
|
||||||
this.broadcastParticipants(sessionId);
|
this.broadcastParticipants(sessionId, true);
|
||||||
this.log.info("Participant left shared session", {
|
this.log.info("Participant left shared session", {
|
||||||
operation: "session_leave_participant",
|
operation: "session_leave_participant",
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -417,6 +417,14 @@ describe("TerminalSessionManager - multiplayer participants", () => {
|
|||||||
expect(session?.detachTimeout).toBeNull();
|
expect(session?.detachTimeout).toBeNull();
|
||||||
expect(session?.participants.size).toBe(1);
|
expect(session?.participants.size).toBe(1);
|
||||||
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
|
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
(ownerWs.send as ReturnType<typeof vi.fn>).mock.lastCall![0] as string,
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
type: "participants",
|
||||||
|
participants: [{ isOwner: true }],
|
||||||
|
});
|
||||||
|
|
||||||
sessionManager.destroySession(id);
|
sessionManager.destroySession(id);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user