Skip to content

Commit bcfbcd8

Browse files
Preserve mobile chat drafts and bound delivery waits
1 parent 4d915b0 commit bcfbcd8

10 files changed

Lines changed: 712 additions & 38 deletions

File tree

‎apps/mobile/README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ those prompt-owned transitions are ignored while a real background transition st
7676
the resume grace period. A brief iOS `inactive` transition alone does not tear down the call or screen
7777
share.
7878

79+
## Chat delivery
80+
81+
Chat reads and sends have a 15-second waiting deadline, including authentication
82+
and response parsing. Leaving a room, disabling chat or unmounting cancels active
83+
requests. This does not undo a message the server may already have saved.
84+
85+
Known rejections keep the draft. Timeouts and interrupted sends warn that delivery
86+
is unknown; sending again is a manual choice and can duplicate the message. Newer
87+
typing is never overwritten: a separate saved-draft block must be restored into an
88+
empty input or explicitly discarded before another send. Drafts are in memory,
89+
not persisted after leaving the session. There is no automatic resend or claim of
90+
exactly-once delivery. Messages with known server IDs are deduplicated as before.
91+
92+
The collapsed badge counts newly received messages, excluding own messages and
93+
initial history. Opening the panel clears the local badge; this is not a shared
94+
read receipt. Tests cover hooks, the panel through DOM/native callback adapters,
95+
and bounded transport behavior; they do not certify native keyboard or layout
96+
behavior on an actual iOS/Android device.
97+
7998
## EAS builds
8099

81100
Link the app to the intended Expo project and provide its UUID through `EAS_PROJECT_ID`. No

‎apps/mobile/app/(app)/session/[id].tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,14 @@ function HostSession({
260260

261261
{/* Chat */}
262262
<ChatPanel
263+
key={sessionId}
263264
messages={chat.messages}
264265
onSend={chat.sendMessage}
265266
sending={chat.sending}
266267
currentUserId={currentUserId}
267268
loading={chat.loading}
269+
historyReady={chat.historyReady}
270+
error={chat.error}
268271
/>
269272
</View>
270273
);
@@ -374,11 +377,14 @@ function ViewerSession({
374377

375378
{/* Chat */}
376379
<ChatPanel
380+
key={sessionId}
377381
messages={chat.messages}
378382
onSend={chat.sendMessage}
379383
sending={chat.sending}
380384
currentUserId={currentUserId}
381385
loading={chat.loading}
386+
historyReady={chat.historyReady}
387+
error={chat.error}
382388
/>
383389
</View>
384390
);
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import React from 'react';
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { ChatPanel } from './ChatPanel';
5+
import type { ChatMessage as Message } from '@pairux/shared-types';
6+
import { useChat } from '../hooks/useChat';
7+
import { chatApi } from '../lib/api/chat';
8+
vi.mock('../lib/api/chat');
9+
10+
// DOM adapters expose native callbacks; the actual panel logic is rendered.
11+
vi.mock('react-native', async () => {
12+
const R = await import('react');
13+
const view = ({ children }: { children?: React.ReactNode }) =>
14+
R.createElement('div', {}, children);
15+
return {
16+
View: view,
17+
Text: ({ children }: { children?: React.ReactNode }) => R.createElement('span', {}, children),
18+
KeyboardAvoidingView: view,
19+
Platform: { OS: 'ios' },
20+
TouchableOpacity: ({
21+
children,
22+
onPress,
23+
disabled,
24+
accessibilityLabel,
25+
}: {
26+
children?: React.ReactNode;
27+
onPress?: () => void;
28+
disabled?: boolean;
29+
accessibilityLabel?: string;
30+
}) =>
31+
R.createElement(
32+
'button',
33+
{ onClick: onPress, disabled, 'aria-label': accessibilityLabel },
34+
children
35+
),
36+
TextInput: ({
37+
value,
38+
onChangeText,
39+
placeholder,
40+
}: {
41+
value: string;
42+
onChangeText: (text: string) => void;
43+
placeholder: string;
44+
}) =>
45+
R.createElement('input', {
46+
value,
47+
placeholder,
48+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChangeText(e.target.value),
49+
}),
50+
FlatList: R.forwardRef(
51+
(
52+
{
53+
data,
54+
renderItem,
55+
ListEmptyComponent,
56+
}: {
57+
data: Message[];
58+
renderItem: (args: { item: Message }) => React.ReactNode;
59+
ListEmptyComponent?: React.ReactNode;
60+
},
61+
ref
62+
) => {
63+
R.useImperativeHandle(ref, () => ({ scrollToEnd() {} }));
64+
return R.createElement(
65+
'div',
66+
{},
67+
data.length
68+
? data.map((item) =>
69+
R.createElement(R.Fragment, { key: item.id }, renderItem({ item }))
70+
)
71+
: ListEmptyComponent
72+
);
73+
}
74+
),
75+
Animated: {
76+
View: view,
77+
Value: class {
78+
setValue() {}
79+
},
80+
timing: () => ({ start() {}, stop() {} }),
81+
},
82+
};
83+
});
84+
vi.mock('./ChatMessage', () => ({
85+
ChatMessage: ({ message }: { message: Message }) => <span>{message.content}</span>,
86+
}));
87+
const message = (id: string, user = 'other'): Message => ({
88+
id,
89+
session_id: 'room',
90+
user_id: user,
91+
display_name: 'Example',
92+
content: id,
93+
message_type: 'text',
94+
created_at: '2026-09-14T00:00:00Z',
95+
recipient_id: null,
96+
});
97+
const input = () => screen.getByPlaceholderText<HTMLInputElement>('Type a message...');
98+
const open = () => fireEvent.click(screen.getByText('Chat'));
99+
100+
describe('chat draft recovery', () => {
101+
it.each(['cancelled', 'ignored'])(
102+
'restores a draft when send is %s before transport',
103+
async (status) => {
104+
const onSend = vi.fn().mockResolvedValue({ status });
105+
render(<ChatPanel messages={[]} onSend={onSend} sending={false} />);
106+
open();
107+
fireEvent.change(input(), { target: { value: 'unsent text' } });
108+
fireEvent.click(screen.getByText('Send'));
109+
await waitFor(() => expect(input().value).toBe('unsent text'));
110+
expect(screen.getByText(/Message not sent/)).toBeDefined();
111+
}
112+
);
113+
it('keeps a draft after the server rejects a message', async () => {
114+
const onSend = vi.fn().mockResolvedValue({ status: 'failed' });
115+
render(<ChatPanel messages={[]} onSend={onSend} sending={false} />);
116+
open();
117+
fireEvent.change(input(), { target: { value: 'keep my text' } });
118+
fireEvent.click(screen.getByText('Send'));
119+
await waitFor(() => expect(input().value).toBe('keep my text'));
120+
expect(onSend).toHaveBeenCalledTimes(1);
121+
});
122+
123+
it('keeps delivery uncertainty visible after a timeout and never auto-resends', async () => {
124+
const onSend = vi.fn().mockResolvedValue({ status: 'unknown' });
125+
render(<ChatPanel messages={[]} onSend={onSend} sending={false} />);
126+
open();
127+
fireEvent.change(input(), { target: { value: 'maybe sent' } });
128+
fireEvent.click(screen.getByText('Send'));
129+
await waitFor(() => expect(screen.getByText(/may have been sent/i)).toBeDefined());
130+
expect(onSend).toHaveBeenCalledTimes(1);
131+
});
132+
133+
it('does not overwrite newer typing with the failed previous message', async () => {
134+
let resolve!: (result: { status: 'failed' }) => void;
135+
const onSend = vi.fn().mockReturnValue(
136+
new Promise((r) => {
137+
resolve = r;
138+
})
139+
);
140+
render(<ChatPanel messages={[]} onSend={onSend} sending={false} />);
141+
open();
142+
fireEvent.change(input(), { target: { value: 'old draft' } });
143+
fireEvent.click(screen.getByText('Send'));
144+
fireEvent.change(input(), { target: { value: 'new draft' } });
145+
await act(async () => {
146+
resolve({ status: 'failed' });
147+
});
148+
expect(input().value).toBe('new draft');
149+
expect(screen.getByText(/old draft/)).toBeDefined();
150+
expect(screen.getByRole('button', { name: 'Send' }).hasAttribute('disabled')).toBe(true);
151+
expect(screen.getByLabelText('Restore saved draft').hasAttribute('disabled')).toBe(true);
152+
fireEvent.change(input(), { target: { value: '' } });
153+
fireEvent.click(screen.getByLabelText('Restore saved draft'));
154+
expect(input().value).toBe('old draft');
155+
expect(screen.queryByLabelText('Restore saved draft')).toBeNull();
156+
expect(onSend).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it('guards rapid double sends even before the parent rerenders', async () => {
160+
const onSend = vi.fn().mockReturnValue(new Promise(() => {}));
161+
render(<ChatPanel messages={[]} onSend={onSend} sending={false} />);
162+
open();
163+
fireEvent.change(input(), { target: { value: 'first' } });
164+
fireEvent.click(screen.getByText('Send'));
165+
fireEvent.change(input(), { target: { value: 'second' } });
166+
fireEvent.click(screen.getByText('Send'));
167+
expect(onSend).toHaveBeenCalledTimes(1);
168+
});
169+
170+
it('does not count initial history, own messages, or messages read while open', () => {
171+
const props = {
172+
onSend: vi.fn(),
173+
sending: false,
174+
currentUserId: 'me',
175+
loading: false,
176+
historyReady: true,
177+
};
178+
const { rerender } = render(<ChatPanel {...props} messages={[message('history')]} />);
179+
expect(screen.queryByText('1')).toBeNull();
180+
rerender(<ChatPanel {...props} messages={[message('history'), message('new')]} />);
181+
expect(screen.getByText('1')).toBeDefined();
182+
rerender(
183+
<ChatPanel {...props} messages={[message('history'), message('new'), message('own', 'me')]} />
184+
);
185+
expect(screen.getByText('1')).toBeDefined();
186+
open();
187+
open();
188+
expect(screen.queryByText('3')).toBeNull();
189+
expect(screen.queryByText('1')).toBeNull();
190+
});
191+
192+
it('baselines the first successful history after a failed attempt, not as unread', () => {
193+
const props = { onSend: vi.fn(), sending: false, currentUserId: 'me', loading: false };
194+
const { rerender } = render(
195+
<ChatPanel {...props} messages={[]} historyReady={false} error="Offline" />
196+
);
197+
rerender(<ChatPanel {...props} messages={[message('history')]} historyReady />);
198+
expect(screen.queryByText('1')).toBeNull();
199+
rerender(<ChatPanel {...props} messages={[message('history'), message('new')]} historyReady />);
200+
expect(screen.getByText('1')).toBeDefined();
201+
});
202+
203+
it('does not count messages before the current user is known', () => {
204+
const props = { onSend: vi.fn(), sending: false, historyReady: true };
205+
const { rerender } = render(<ChatPanel {...props} messages={[]} />);
206+
rerender(<ChatPanel {...props} messages={[message('own', 'me')]} />);
207+
expect(screen.queryByText('1')).toBeNull();
208+
rerender(<ChatPanel {...props} currentUserId="me" messages={[message('own', 'me')]} />);
209+
rerender(
210+
<ChatPanel {...props} currentUserId="me" messages={[message('own', 'me'), message('new')]} />
211+
);
212+
expect(screen.getByText('1')).toBeDefined();
213+
});
214+
});
215+
216+
function SessionChat({
217+
sessionId = 'room',
218+
enabled = true,
219+
}: {
220+
sessionId?: string;
221+
enabled?: boolean;
222+
}) {
223+
const chat = useChat({ sessionId, enabled });
224+
return (
225+
<ChatPanel
226+
key={sessionId}
227+
messages={chat.messages}
228+
onSend={chat.sendMessage}
229+
sending={chat.sending}
230+
loading={chat.loading}
231+
historyReady={chat.historyReady}
232+
error={chat.error}
233+
currentUserId="me"
234+
/>
235+
);
236+
}
237+
238+
describe('panel and hook lifecycle together', () => {
239+
it('restores an in-flight draft with an uncertainty warning when chat is disabled', async () => {
240+
vi.mocked(chatApi.getHistory).mockResolvedValue({ data: { messages: [], hasMore: false } });
241+
vi.mocked(chatApi.send).mockImplementationOnce(
242+
(_room, _content, _participant, signal) =>
243+
new Promise((resolve) => {
244+
signal?.addEventListener(
245+
'abort',
246+
() => resolve({ error: 'Aborted', failureKind: 'unknown' }),
247+
{ once: true }
248+
);
249+
})
250+
);
251+
const { rerender } = render(<SessionChat />);
252+
open();
253+
fireEvent.change(input(), { target: { value: 'pending draft' } });
254+
fireEvent.click(screen.getByText('Send'));
255+
rerender(<SessionChat enabled={false} />);
256+
await waitFor(() => expect(input().value).toBe('pending draft'));
257+
expect(screen.getByText(/may have been sent/)).toBeDefined();
258+
expect(chatApi.send).toHaveBeenCalledTimes(1);
259+
});
260+
261+
it('does not put a late old-room response or draft into a new room', async () => {
262+
vi.mocked(chatApi.getHistory).mockResolvedValue({ data: { messages: [], hasMore: false } });
263+
let complete!: (value: { data: Message }) => void;
264+
vi.mocked(chatApi.send).mockReturnValueOnce(
265+
new Promise((resolve) => {
266+
complete = resolve;
267+
})
268+
);
269+
const { rerender } = render(<SessionChat />);
270+
open();
271+
fireEvent.change(input(), { target: { value: 'old room secret' } });
272+
fireEvent.click(screen.getByText('Send'));
273+
rerender(<SessionChat sessionId="new-room" />);
274+
open();
275+
fireEvent.change(input(), { target: { value: 'new room draft' } });
276+
await act(async () => {
277+
complete({ data: { ...message('old'), content: 'old room secret' } });
278+
});
279+
expect(input().value).toBe('new room draft');
280+
expect(screen.queryByText('old room secret')).toBeNull();
281+
expect(screen.queryByText(/may have been sent/)).toBeNull();
282+
});
283+
});

0 commit comments

Comments
 (0)