Skip to content

Commit 6543423

Browse files
committed
feat: enhance participant management and presence handling across components
1 parent c500c79 commit 6543423

11 files changed

Lines changed: 365 additions & 39 deletions

File tree

apps/desktop/src/renderer/components/capture/CapturePreview.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,11 @@ export function CapturePreview({
201201
allowControl: Boolean(session?.settings?.allowControl),
202202
onViewerJoined: (viewerId: string) => {
203203
console.log('[CapturePreview] Viewer joined:', viewerId);
204+
void refreshSession();
204205
},
205206
onViewerLeft: (viewerId: string) => {
206207
console.log('[CapturePreview] Viewer left:', viewerId);
208+
void refreshSession();
207209
},
208210
onInputReceived: (_viewerId: string, input: InputMessage) => {
209211
void injectEvent(input.event);
@@ -212,8 +214,14 @@ export function CapturePreview({
212214
// TODO: Update remote cursor position
213215
},
214216
}),
215-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
216-
[session?.id, currentUserId, stream, session?.settings?.allowControl, injectEvent]
217+
[
218+
session?.id,
219+
currentUserId,
220+
stream,
221+
session?.settings.allowControl,
222+
injectEvent,
223+
refreshSession,
224+
]
217225
);
218226

219227
// Always call both hooks (Rules of Hooks) — use results from the active one
@@ -1085,6 +1093,7 @@ export function CapturePreview({
10851093
<ChatPanel
10861094
sessionId={session.id}
10871095
currentUserId={currentUserId}
1096+
participants={participants.filter((p) => !p.left_at)}
10881097
isHost={canModerateSession}
10891098
mutedParticipants={mutedParticipants}
10901099
onGrantControl={

apps/desktop/src/renderer/components/chat/ChatPanel.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface ChatPanelProps {
2222
sessionId: string;
2323
currentUserId?: string | null;
2424
participantId?: string;
25+
participants?: SessionParticipant[];
2526
isCollapsed?: boolean;
2627
onToggleCollapse?: () => void;
2728
isHost?: boolean;
@@ -38,6 +39,7 @@ export function ChatPanel({
3839
sessionId,
3940
currentUserId,
4041
participantId,
42+
participants: participantsProp,
4143
isCollapsed: controlledCollapsed,
4244
onToggleCollapse,
4345
isHost = false,
@@ -56,9 +58,11 @@ export function ChatPanel({
5658
participantId,
5759
});
5860

59-
const { participants, isLoading: participantsLoading } = useParticipants({
61+
const { participants: liveParticipants, isLoading: participantsLoading } = useParticipants({
6062
sessionId,
6163
});
64+
const participants = participantsProp ?? liveParticipants;
65+
const participantsLoadingState = participantsProp ? false : participantsLoading;
6266

6367
// Message input state
6468
const [inputValue, setInputValue] = useState('');
@@ -186,7 +190,7 @@ export function ChatPanel({
186190
participants={participants}
187191
currentUserId={currentUserId}
188192
currentParticipantId={participantId}
189-
isLoading={participantsLoading}
193+
isLoading={participantsLoadingState}
190194
isHost={isHost}
191195
mutedParticipants={mutedParticipants}
192196
onGrantControl={onGrantControl}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import type { SessionParticipant } from '@pairux/shared-types';
4+
import { useParticipants } from './useParticipants';
5+
6+
const mockInvoke = vi.fn();
7+
8+
vi.mock('@/lib/ipc', () => ({
9+
getElectronAPI: () => ({
10+
invoke: mockInvoke,
11+
}),
12+
}));
13+
14+
function makeParticipant(
15+
overrides: Partial<SessionParticipant> & Pick<SessionParticipant, 'id' | 'joined_at'>
16+
): SessionParticipant {
17+
return {
18+
id: overrides.id,
19+
session_id: overrides.session_id ?? 'session-1',
20+
user_id: overrides.user_id ?? null,
21+
display_name: overrides.display_name ?? overrides.id,
22+
role: overrides.role ?? 'viewer',
23+
control_state: overrides.control_state ?? 'view-only',
24+
is_backup_host: overrides.is_backup_host ?? false,
25+
joined_at: overrides.joined_at,
26+
left_at: overrides.left_at ?? null,
27+
last_seen_at: overrides.last_seen_at ?? null,
28+
connection_status: overrides.connection_status ?? 'connected',
29+
} as SessionParticipant;
30+
}
31+
32+
describe('chat/useParticipants (desktop)', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
vi.useFakeTimers();
36+
});
37+
38+
afterEach(() => {
39+
vi.useRealTimers();
40+
});
41+
42+
it('filters role=left participants and refreshes on polling', async () => {
43+
mockInvoke
44+
.mockResolvedValueOnce({
45+
success: true,
46+
participants: [
47+
makeParticipant({ id: 'host', role: 'host', joined_at: '2024-01-01T10:00:00Z' }),
48+
makeParticipant({ id: 'viewer', joined_at: '2024-01-01T10:01:00Z' }),
49+
],
50+
})
51+
.mockResolvedValueOnce({
52+
success: true,
53+
participants: [
54+
makeParticipant({ id: 'host', role: 'host', joined_at: '2024-01-01T10:00:00Z' }),
55+
makeParticipant({
56+
id: 'viewer',
57+
joined_at: '2024-01-01T10:01:00Z',
58+
left_at: '2024-01-01T10:05:00Z',
59+
}),
60+
],
61+
});
62+
63+
const { result, unmount } = renderHook(() => useParticipants({ sessionId: 'session-1' }));
64+
65+
await act(async () => {
66+
await Promise.resolve();
67+
});
68+
expect(result.current.participants.map((p) => p.id)).toEqual(['host', 'viewer']);
69+
70+
await act(async () => {
71+
vi.advanceTimersByTime(5000);
72+
await Promise.resolve();
73+
});
74+
75+
expect(result.current.participants.map((p) => p.id)).toEqual(['host']);
76+
77+
unmount();
78+
});
79+
});

apps/desktop/src/renderer/components/chat/useParticipants.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ interface UseParticipantsReturn {
1313
refetch: () => Promise<void>;
1414
}
1515

16+
function isActiveParticipant(participant: SessionParticipant): boolean {
17+
return !participant.left_at;
18+
}
19+
20+
function sortParticipants(participants: SessionParticipant[]): SessionParticipant[] {
21+
participants.sort((a, b) => {
22+
if (a.role === 'host' && b.role !== 'host') return -1;
23+
if (b.role === 'host' && a.role !== 'host') return 1;
24+
return new Date(a.joined_at).getTime() - new Date(b.joined_at).getTime();
25+
});
26+
return participants;
27+
}
28+
1629
// Poll interval for participant updates (in ms)
1730
const POLL_INTERVAL = 5000;
1831

@@ -33,15 +46,7 @@ export function useParticipants({ sessionId }: UseParticipantsOptions): UseParti
3346
throw new Error(result.error);
3447
}
3548

36-
// Filter to only active participants (no left_at)
37-
const active = result.participants.filter((p: SessionParticipant) => !p.left_at);
38-
// Sort: host first, then by joined_at
39-
active.sort((a: SessionParticipant, b: SessionParticipant) => {
40-
if (a.role === 'host' && b.role !== 'host') return -1;
41-
if (b.role === 'host' && a.role !== 'host') return 1;
42-
return new Date(a.joined_at).getTime() - new Date(b.joined_at).getTime();
43-
});
44-
setParticipants(active);
49+
setParticipants(sortParticipants(result.participants.filter(isActiveParticipant)));
4550
} catch (err) {
4651
setError(err instanceof Error ? err.message : 'Failed to fetch participants');
4752
} finally {

apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ interface UseWebRTCViewerAPIOptions {
4949
onControlStateChange?: (state: ControlStateUI) => void;
5050
onCursorUpdate?: (cursor: CursorPositionMessage) => void;
5151
onKicked?: (reason?: string) => void;
52+
onPresenceChange?: () => void;
5253
}
5354

5455
interface UseWebRTCViewerAPIReturn {
@@ -78,6 +79,7 @@ export function useWebRTCViewerAPI({
7879
onControlStateChange,
7980
onCursorUpdate,
8081
onKicked,
82+
onPresenceChange,
8183
}: UseWebRTCViewerAPIOptions): UseWebRTCViewerAPIReturn {
8284
const [connectionState, setConnectionState] = useState<ConnectionState>('idle');
8385
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
@@ -120,11 +122,13 @@ export function useWebRTCViewerAPI({
120122
const onControlStateChangeRef = useRef(onControlStateChange);
121123
const onCursorUpdateRef = useRef(onCursorUpdate);
122124
const onKickedRef = useRef(onKicked);
125+
const onPresenceChangeRef = useRef(onPresenceChange);
123126
const disconnectRef = useRef<(() => void) | undefined>(undefined);
124127

125128
onControlStateChangeRef.current = onControlStateChange;
126129
onCursorUpdateRef.current = onCursorUpdate;
127130
onKickedRef.current = onKicked;
131+
onPresenceChangeRef.current = onPresenceChange;
128132

129133
const getSignalSenderId = useCallback(() => signalSenderIdRef.current, []);
130134

@@ -880,6 +884,7 @@ export function useWebRTCViewerAPI({
880884
const { presences } = JSON.parse(event.data as string) as {
881885
presences: { user_id: string; role: string }[];
882886
};
887+
onPresenceChangeRef.current?.();
883888
for (const presence of presences) {
884889
if (presence.role === 'host') {
885890
console.log('[WebRTCViewer] Host is present:', presence.user_id);
@@ -895,6 +900,7 @@ export function useWebRTCViewerAPI({
895900
const { presences } = JSON.parse(event.data as string) as {
896901
presences: { user_id: string; role: string }[];
897902
};
903+
onPresenceChangeRef.current?.();
898904
for (const presence of presences) {
899905
if (presence.role === 'host') {
900906
console.log('[WebRTCViewer] Host left');

apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface UseWebRTCViewerSFUAPIOptions {
4040
onControlStateChange?: (state: ControlStateUI) => void;
4141
onCursorUpdate?: (cursor: CursorPositionMessage) => void;
4242
onKicked?: (reason?: string) => void;
43+
onPresenceChange?: () => void;
4344
}
4445

4546
interface UseWebRTCViewerSFUAPIReturn {
@@ -84,6 +85,7 @@ export function useWebRTCViewerSFUAPI({
8485
onControlStateChange,
8586
onCursorUpdate,
8687
onKicked,
88+
onPresenceChange,
8789
}: UseWebRTCViewerSFUAPIOptions): UseWebRTCViewerSFUAPIReturn {
8890
const [connectionState, setConnectionState] = useState<ConnectionState>('idle');
8991
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
@@ -106,13 +108,15 @@ export function useWebRTCViewerSFUAPI({
106108
const onControlStateChangeRef = useRef(onControlStateChange);
107109
const onCursorUpdateRef = useRef(onCursorUpdate);
108110
const onKickedRef = useRef(onKicked);
111+
const onPresenceChangeRef = useRef(onPresenceChange);
109112
const onStreamReadyRef = useRef(onStreamReady);
110113
const onStreamEndedRef = useRef(onStreamEnded);
111114
const disconnectRef = useRef<(() => void) | undefined>(undefined);
112115

113116
onControlStateChangeRef.current = onControlStateChange;
114117
onCursorUpdateRef.current = onCursorUpdate;
115118
onKickedRef.current = onKicked;
119+
onPresenceChangeRef.current = onPresenceChange;
116120
onStreamReadyRef.current = onStreamReady;
117121
onStreamEndedRef.current = onStreamEnded;
118122

@@ -445,6 +449,7 @@ export function useWebRTCViewerSFUAPI({
445449

446450
// Detect host disconnect/reconnect
447451
room.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
452+
onPresenceChangeRef.current?.();
448453
try {
449454
const meta = JSON.parse(participant.metadata ?? '{}') as { role?: string };
450455
if (meta.role === 'host') {
@@ -456,6 +461,7 @@ export function useWebRTCViewerSFUAPI({
456461
});
457462

458463
room.on(RoomEvent.ParticipantConnected, (participant: RemoteParticipant) => {
464+
onPresenceChangeRef.current?.();
459465
try {
460466
const meta = JSON.parse(participant.metadata ?? '{}') as { role?: string };
461467
if (meta.role === 'host') {

apps/desktop/src/renderer/routes/viewer.test.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
33
import { MemoryRouter, Route, Routes } from 'react-router-dom';
44
import { ViewerPage } from './viewer';
55
import type { Session, SessionParticipant } from '@pairux/shared-types';
@@ -72,16 +72,18 @@ const mockSFUHookResult = {
7272
};
7373

7474
vi.mock('@/hooks/useWebRTCViewerAPI', () => ({
75-
useWebRTCViewerAPI: (opts: { onKicked?: () => void }) => {
75+
useWebRTCViewerAPI: (opts: { onKicked?: () => void; onPresenceChange?: () => void }) => {
7676
// Store callback for testing
7777
(mockP2PHookResult as Record<string, unknown>)._onKicked = opts.onKicked;
78+
(mockP2PHookResult as Record<string, unknown>)._onPresenceChange = opts.onPresenceChange;
7879
return mockP2PHookResult;
7980
},
8081
}));
8182

8283
vi.mock('@/hooks/useWebRTCViewerSFUAPI', () => ({
83-
useWebRTCViewerSFUAPI: (opts: { onKicked?: () => void }) => {
84+
useWebRTCViewerSFUAPI: (opts: { onKicked?: () => void; onPresenceChange?: () => void }) => {
8485
(mockSFUHookResult as Record<string, unknown>)._onKicked = opts.onKicked;
86+
(mockSFUHookResult as Record<string, unknown>)._onPresenceChange = opts.onPresenceChange;
8587
return mockSFUHookResult;
8688
},
8789
}));
@@ -404,4 +406,47 @@ describe('ViewerPage', () => {
404406

405407
setIntervalSpy.mockRestore();
406408
});
409+
410+
it('refreshes participant list immediately on presence change', async () => {
411+
mockInvoke
412+
.mockResolvedValueOnce({
413+
success: true,
414+
session: makeSession(),
415+
participants: makeParticipants(),
416+
})
417+
.mockResolvedValueOnce({
418+
success: true,
419+
session: makeSession(),
420+
participants: [
421+
...makeParticipants(),
422+
{
423+
id: 'part-viewer-2',
424+
session_id: 'session-1',
425+
user_id: 'user-2',
426+
role: 'viewer',
427+
display_name: 'Viewer Two',
428+
control_state: 'view-only',
429+
is_backup_host: false,
430+
connection_status: 'connected',
431+
last_seen_at: null,
432+
joined_at: new Date().toISOString(),
433+
left_at: null,
434+
} satisfies SessionParticipant,
435+
],
436+
});
437+
438+
renderWithRouter();
439+
440+
await waitFor(() => {
441+
expect(screen.getByText('2 participants')).toBeInTheDocument();
442+
});
443+
444+
await act(async () => {
445+
(mockP2PHookResult as unknown as { _onPresenceChange?: () => void })._onPresenceChange?.();
446+
});
447+
448+
await waitFor(() => {
449+
expect(screen.getByText('3 participants')).toBeInTheDocument();
450+
});
451+
});
407452
});

0 commit comments

Comments
 (0)