Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ pnpm mobile:android
pnpm mobile:ios
```

## Call continuity

The mobile host and viewer intentionally close their SSE, WebRTC peer, media, and stats resources
when the app leaves the foreground. If a call was active or still connecting, returning to the
foreground starts exactly one fresh connection through the normal authentication and signaling
path. A server heartbeat watchdog also replaces a connection that has stopped receiving SSE
heartbeats for 75 seconds.

Reconnects preserve the user's microphone mute choice. Async media and signaling callbacks are
scoped to a connection generation, so a late callback from a backgrounded or unmounted screen
cannot restore stale peers, viewers, chat history, or microphone state. This is foreground call
recovery, not background audio support.

An active screen share ends when the app reaches the background and must be started again after
returning. The capture hook owns that teardown so its UI cannot report a stopped native track as
still sharing. A brief iOS `inactive` transition alone does not tear down the call or screen share.

## EAS builds

Link the app to the intended Expo project and provide its UUID through `EAS_PROJECT_ID`. No
Expand Down
144 changes: 141 additions & 3 deletions apps/mobile/src/hooks/useChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ describe('useChat', () => {
vi.clearAllMocks();
});

it('should initialize with loading state', () => {
it('should initialize without loading when disabled', () => {
vi.mocked(chatApi.getHistory).mockResolvedValue({
data: { messages: [], hasMore: false },
});

const { result } = renderHook(() => useChat({ sessionId: 'session-1', enabled: false }));

expect(result.current.loading).toBe(true);
expect(result.current.loading).toBe(false);
expect(result.current.messages).toEqual([]);
expect(result.current.sending).toBe(false);
expect(result.current.error).toBeNull();
Expand All @@ -53,7 +53,7 @@ describe('useChat', () => {
const { result } = renderHook(() => useChat({ sessionId: 'session-1', enabled: false }));

expect(chatApi.getHistory).not.toHaveBeenCalled();
expect(result.current.loading).toBe(true);
expect(result.current.loading).toBe(false);
});

it('should send messages', async () => {
Expand Down Expand Up @@ -138,4 +138,142 @@ describe('useChat', () => {

expect(result.current.error).toBe('Send failed');
});

it('ignores history returned by a previous session generation', async () => {
let resolveOldHistory!: (value: {
data: { messages: ChatMessage[]; hasMore: boolean };
}) => void;
vi.mocked(chatApi.getHistory)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveOldHistory = resolve;
})
)
.mockResolvedValueOnce({
data: {
messages: [{ ...mockMessage, id: 'msg-new', session_id: 'session-2' }],
hasMore: false,
},
});

const { result, rerender } = renderHook(
({ sessionId }: { sessionId: string }) => useChat({ sessionId, enabled: true }),
{ initialProps: { sessionId: 'session-1' } }
);

rerender({ sessionId: 'session-2' });
await waitFor(() => {
expect(result.current.messages.map((message) => message.id)).toEqual(['msg-new']);
});

await act(async () => {
resolveOldHistory({ data: { messages: [mockMessage], hasMore: false } });
await Promise.resolve();
});

expect(result.current.messages.map((message) => message.id)).toEqual(['msg-new']);
});

it('does not overlap polling requests within the same generation', async () => {
vi.useFakeTimers();
let resolveFirstPoll!: (value: { data: { messages: ChatMessage[]; hasMore: boolean } }) => void;
vi.mocked(chatApi.getHistory)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstPoll = resolve;
})
)
.mockResolvedValue({ data: { messages: [], hasMore: false } });

try {
renderHook(() => useChat({ sessionId: 'session-1', enabled: true }));
await act(async () => {
await Promise.resolve();
});
expect(chatApi.getHistory).toHaveBeenCalledTimes(1);

await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(chatApi.getHistory).toHaveBeenCalledTimes(1);

await act(async () => {
resolveFirstPoll({ data: { messages: [], hasMore: false } });
await Promise.resolve();
await vi.advanceTimersByTimeAsync(2000);
});
expect(chatApi.getHistory).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});

it('ignores a send response after the active session changes', async () => {
let resolveSend!: (value: { data: ChatMessage }) => void;
vi.mocked(chatApi.getHistory).mockResolvedValue({
data: { messages: [], hasMore: false },
});
vi.mocked(chatApi.send).mockReturnValueOnce(
new Promise((resolve) => {
resolveSend = resolve;
})
);

const { result, rerender } = renderHook(
({ sessionId }: { sessionId: string }) => useChat({ sessionId, enabled: true }),
{ initialProps: { sessionId: 'session-1' } }
);
await waitFor(() => expect(result.current.loading).toBe(false));

let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('old session message');
});
rerender({ sessionId: 'session-2' });
await waitFor(() => expect(result.current.loading).toBe(false));

await act(async () => {
resolveSend({ data: mockMessage });
await sendPromise;
});

expect(result.current.messages).toEqual([]);
expect(result.current.sending).toBe(false);
});

it('clears a stale sending state when chat is disabled and re-enabled', async () => {
let resolveSend!: (value: { data: ChatMessage }) => void;
vi.mocked(chatApi.getHistory).mockResolvedValue({
data: { messages: [], hasMore: false },
});
vi.mocked(chatApi.send).mockReturnValueOnce(
new Promise((resolve) => {
resolveSend = resolve;
})
);

const { result, rerender } = renderHook(
({ enabled }: { enabled: boolean }) => useChat({ sessionId: 'session-1', enabled }),
{ initialProps: { enabled: true } }
);
await waitFor(() => expect(result.current.loading).toBe(false));

let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('message before disable');
});
expect(result.current.sending).toBe(true);

rerender({ enabled: false });
expect(result.current.sending).toBe(false);
rerender({ enabled: true });

await act(async () => {
resolveSend({ data: mockMessage });
await sendPromise;
});

expect(result.current.messages).toEqual([]);
expect(result.current.sending).toBe(false);
});
});
144 changes: 105 additions & 39 deletions apps/mobile/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,72 +36,122 @@ export function useChat({

const seenIdsRef = useRef<Set<string>>(new Set());
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollGenerationRef = useRef(0);
const pollInFlightRef = useRef<number | null>(null);
const sendOperationRef = useRef<symbol | null>(null);
const mountedRef = useRef(true);
const previousSessionIdRef = useRef(sessionId);

// Fetch messages
const fetchMessages = useCallback(async () => {
try {
const result = await chatApi.getHistory(sessionId, { limit: 100 });
const fetchMessages = useCallback(
async (generation: number) => {
if (pollInFlightRef.current === generation) return;
pollInFlightRef.current = generation;

if (result.error) {
setError(result.error);
return;
}
try {
const result = await chatApi.getHistory(sessionId, { limit: 100 });
if (!mountedRef.current || pollGenerationRef.current !== generation) return;

if (result.data?.messages) {
const newMessages: ChatMessage[] = [];
for (const msg of result.data.messages) {
if (!seenIdsRef.current.has(msg.id)) {
seenIdsRef.current.add(msg.id);
newMessages.push(msg);
}
if (result.error) {
setError(result.error);
return;
}

if (newMessages.length > 0) {
setMessages((prev) => {
const combined = [...prev, ...newMessages];
// Sort by timestamp ascending
combined.sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return combined;
});
}
if (result.data?.messages) {
const newMessages: ChatMessage[] = [];
for (const msg of result.data.messages) {
if (!seenIdsRef.current.has(msg.id)) {
seenIdsRef.current.add(msg.id);
newMessages.push(msg);
}
}

setError(null);
if (newMessages.length > 0) {
setMessages((prev) => {
const combined = [...prev, ...newMessages];
// Sort by timestamp ascending
combined.sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return combined;
});
}

setError(null);
}
} catch {
if (mountedRef.current && pollGenerationRef.current === generation) {
setError('Failed to fetch messages');
}
} finally {
if (pollInFlightRef.current === generation) {
pollInFlightRef.current = null;
}
if (mountedRef.current && pollGenerationRef.current === generation) {
setLoading(false);
}
}
} catch {
setError('Failed to fetch messages');
} finally {
setLoading(false);
}
}, [sessionId]);
},
[sessionId]
);

// Start polling when enabled
useEffect(() => {
if (!enabled) return;
const generation = ++pollGenerationRef.current;
const sessionChanged = previousSessionIdRef.current !== sessionId;
previousSessionIdRef.current = sessionId;

// A generation change invalidates any send started by the previous chat
// lifecycle, including enable/disable transitions within the same session.
if (sendOperationRef.current) {
sendOperationRef.current = null;
setSending(false);
}

if (sessionChanged) {
seenIdsRef.current = new Set();
setMessages([]);
setError(null);
setLoading(true);
}

if (!enabled) {
setLoading(false);
return undefined;
}

void fetchMessages();
setLoading(true);
void fetchMessages(generation);

pollIntervalRef.current = setInterval(() => {
void fetchMessages();
void fetchMessages(generation);
}, POLL_INTERVAL);

return () => {
if (pollGenerationRef.current === generation) {
pollGenerationRef.current += 1;
}
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
}, [enabled, fetchMessages]);
}, [enabled, fetchMessages, sessionId]);

// Send message
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim()) return;
const trimmed = content.trim();
if (!trimmed || sendOperationRef.current) return;

const generation = pollGenerationRef.current;
const operation = Symbol('chat-send');
sendOperationRef.current = operation;
setSending(true);

try {
const result = await chatApi.send(sessionId, content.trim(), participantId);
const result = await chatApi.send(sessionId, trimmed, participantId);
if (!mountedRef.current || pollGenerationRef.current !== generation) return;

if (result.error) {
setError(result.error);
Expand All @@ -116,14 +166,30 @@ export function useChat({

setError(null);
} catch {
setError('Failed to send message');
if (mountedRef.current && pollGenerationRef.current === generation) {
setError('Failed to send message');
}
} finally {
setSending(false);
if (sendOperationRef.current === operation) {
sendOperationRef.current = null;
if (mountedRef.current && pollGenerationRef.current === generation) {
setSending(false);
}
}
}
},
[sessionId, participantId]
);

useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
pollGenerationRef.current += 1;
sendOperationRef.current = null;
};
}, []);

return {
messages,
loading,
Expand Down
Loading
Loading