Skip to content

Commit 6bce56a

Browse files
authored
Chore/docs retry and modal race fixes (#1289)
Closes #1043 Closes #1210 Closes #1239 Closes #1247
1 parent fcd82d8 commit 6bce56a

18 files changed

Lines changed: 1680 additions & 163 deletions

Dechat/dex_with_fiat_frontend/src/components/ChatMessages.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ interface ChatMessagesProps {
2222
actionType: string,
2323
data?: Record<string, unknown>,
2424
) => void;
25+
/**
26+
* Resend a message that failed to send. Receives the failed message's id and
27+
* its original content, ready to be submitted again as-is.
28+
*/
29+
onRetry?: (messageId: string, content: string) => void | Promise<void>;
2530
isLoading?: boolean;
2631
searchQuery?: string;
2732
}
@@ -107,6 +112,7 @@ function HelpCard({
107112
export default function ChatMessages({
108113
messages: allMessages,
109114
onActionClick,
115+
onRetry,
110116
isLoading = false,
111117
searchQuery = '',
112118
}: ChatMessagesProps) {
@@ -402,6 +408,7 @@ export default function ChatMessages({
402408
key={message.id}
403409
message={message}
404410
onActionClick={onActionClick}
411+
onRetry={onRetry}
405412
shouldAnimate={isReadyToAnimate && !isLoadingMore && !seenMessageIds.current.has(message.id)}
406413
/>
407414
))}

Dechat/dex_with_fiat_frontend/src/components/Message.tsx

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import React from 'react';
1212
import ReactMarkdown from 'react-markdown';
1313
import type { Components } from 'react-markdown';
1414
import { toDate } from '@/lib/messageUtils';
15+
import { MAX_AUTO_RETRIES, useMessageRetry } from '@/hooks/useMessageRetry';
1516
import { useTranslation } from '@/contexts/TranslationContext';
1617
import { motion, useReducedMotion } from 'framer-motion';
1718
import CopyButton from '@/components/ui/CopyButton';
@@ -23,7 +24,12 @@ interface MessageProps {
2324
actionType: string,
2425
data?: Record<string, unknown>,
2526
) => void;
26-
onRetry?: (messageId: string) => void;
27+
/**
28+
* Resend a message that failed to send. Receives the message id and the
29+
* *original* content the user typed (from `originalPayload` when available),
30+
* so the caller never has to reconstruct it and the user never has to retype.
31+
*/
32+
onRetry?: (messageId: string, content: string) => void | Promise<void>;
2733
shouldAnimate?: boolean;
2834
}
2935

@@ -44,6 +50,19 @@ export default function Message({ message, onActionClick, onRetry, shouldAnimate
4450
const isPending = message.metadata?.status === 'pending';
4551
const isFailed = message.metadata?.status === 'failed';
4652

53+
// Resend uses the payload captured at send time so the user's original text
54+
// survives any post-failure rewrite of `content`.
55+
const retryContent = message.originalPayload?.content ?? message.content;
56+
const retry = useMessageRetry({
57+
messageId: message.id,
58+
content: retryContent,
59+
isFailed: hasError,
60+
onRetry,
61+
});
62+
// Attempts already recorded on the message, plus the ones this component has
63+
// made since it mounted.
64+
const totalRetryAttempts = (message.error?.retryAttempts ?? 0) + retry.attempts;
65+
4766

4867
// Currency conversion hook for transaction amounts
4968
const amountForConversion = message.metadata?.transactionData?.amountIn
@@ -216,6 +235,8 @@ export default function Message({ message, onActionClick, onRetry, shouldAnimate
216235
{/* Error State */}
217236
{hasError && (
218237
<div
238+
data-testid="message-error"
239+
role="alert"
219240
className={`mt-3 inline-flex flex-col gap-2 rounded-lg border px-3 py-2 text-xs ${
220241
isDarkMode
221242
? 'border-red-700 bg-red-950/40 text-red-200'
@@ -228,23 +249,51 @@ export default function Message({ message, onActionClick, onRetry, shouldAnimate
228249
{message.error?.message || 'Failed to send message'}
229250
</span>
230251
</div>
231-
{message.error?.retryAttempts && message.error.retryAttempts > 0 && (
252+
{totalRetryAttempts > 0 && (
232253
<div className="text-xs opacity-75">
233-
Retry attempts: {message.error.retryAttempts}
254+
Retry attempts: {totalRetryAttempts}
234255
</div>
235256
)}
236257
{onRetry && (
237-
<button
238-
onClick={() => onRetry(message.id)}
239-
className={`mt-2 flex items-center justify-center gap-2 px-3 py-1 rounded-lg text-xs font-medium transition-all transform hover:scale-105 active:scale-95 ${
240-
isDarkMode
241-
? 'bg-red-700/40 hover:bg-red-700/60 border border-red-600'
242-
: 'bg-red-100 hover:bg-red-200 border border-red-300'
243-
}`}
244-
>
245-
<RotateCcw className="w-3 h-3" />
246-
Retry
247-
</button>
258+
<>
259+
<div
260+
className="text-xs opacity-75"
261+
aria-live="polite"
262+
data-testid="message-retry-status"
263+
>
264+
{retry.isRetrying
265+
? t('chat.resending')
266+
: retry.secondsUntilNextRetry !== null
267+
? t('chat.retry_countdown', {
268+
seconds: retry.secondsUntilNextRetry,
269+
attempt: retry.attempts + 1,
270+
max: MAX_AUTO_RETRIES,
271+
})
272+
: t('chat.retry_exhausted', {
273+
max: MAX_AUTO_RETRIES,
274+
})}
275+
</div>
276+
<button
277+
type="button"
278+
onClick={retry.retryNow}
279+
disabled={retry.isRetrying}
280+
data-testid="message-retry-button"
281+
aria-label={t('chat.retry_message', {
282+
content: retryContent,
283+
})}
284+
title={retryContent}
285+
className={`mt-2 flex items-center justify-center gap-2 px-3 py-1 rounded-lg text-xs font-medium transition-all transform hover:scale-105 active:scale-95 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100 ${
286+
isDarkMode
287+
? 'bg-red-700/40 hover:bg-red-700/60 border border-red-600'
288+
: 'bg-red-100 hover:bg-red-200 border border-red-300'
289+
}`}
290+
>
291+
<RotateCcw
292+
className={`w-3 h-3 ${retry.isRetrying ? 'animate-spin' : ''}`}
293+
/>
294+
{retry.isRetrying ? t('common.loading') : t('common.retry')}
295+
</button>
296+
</>
248297
)}
249298
</div>
250299
)}
Lines changed: 76 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { renderHook, act } from '@testing-library/react';
3-
import { render, screen, waitFor } from '@testing-library/react';
2+
import { act, render, screen } from '@testing-library/react';
43
import OfflineStatusBanner from './OfflineStatusBanner';
5-
import * as offlineMessageQueue from '@/lib/offlineMessageQueue';
4+
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
5+
import { subscribeToQueuedMessageCount } from '@/lib/offlineMessageQueue';
66

77
// Mock dependencies
88
vi.mock('@/hooks/useOnlineStatus', () => ({
@@ -26,14 +26,42 @@ vi.mock('@/lib/offlineStatusSchema', () => ({
2626
}));
2727

2828
vi.mock('@/lib/offlineMessageQueue', () => ({
29-
subscribeToQueuedMessageCount: vi.fn(),
29+
subscribeToQueuedMessageCount: vi.fn(() => () => {}),
3030
setQueuedMessageCount: vi.fn(),
3131
getQueuedMessageCount: vi.fn(() => 0),
3232
}));
3333

34+
const mockedUseOnlineStatus = vi.mocked(useOnlineStatus);
35+
const mockedSubscribe = vi.mocked(subscribeToQueuedMessageCount);
36+
37+
/** Elapse the 300ms initial loading gate so it cannot mask later renders. */
38+
function settleLoadingGate() {
39+
act(() => {
40+
vi.advanceTimersByTime(300);
41+
});
42+
}
43+
44+
/** Point `useOnlineStatus` at a value the test can flip between renders. */
45+
function stubOnlineStatus(getIsOnline: () => boolean, wasOffline = false) {
46+
const resetWasOffline = vi.fn();
47+
mockedUseOnlineStatus.mockImplementation(() => ({
48+
get isOnline() {
49+
return getIsOnline();
50+
},
51+
wasOffline,
52+
resetWasOffline,
53+
}));
54+
return resetWasOffline;
55+
}
56+
3457
describe('OfflineStatusBanner - Optimistic UI Updates', () => {
3558
beforeEach(() => {
3659
vi.clearAllMocks();
60+
mockedSubscribe.mockImplementation(() => () => {});
61+
// Fake timers are required for the 300ms loading skeleton and the 500ms
62+
// reconnect dismissal. Assertions below are made directly after an explicit
63+
// `advanceTimersByTime` rather than through `waitFor`, whose polling never
64+
// fires while the clock is frozen.
3765
vi.useFakeTimers();
3866
});
3967

@@ -42,138 +70,104 @@ describe('OfflineStatusBanner - Optimistic UI Updates', () => {
4270
vi.useRealTimers();
4371
});
4472

45-
it('should show banner immediately when going offline', async () => {
46-
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
47-
(useOnlineStatus as any).mockReturnValue({
48-
isOnline: false,
49-
wasOffline: false,
50-
resetWasOffline: vi.fn(),
51-
});
73+
it('should show banner immediately when going offline', () => {
74+
stubOnlineStatus(() => false);
5275

5376
render(<OfflineStatusBanner />);
5477

55-
await waitFor(() => {
56-
expect(screen.getByRole('status')).toBeInTheDocument();
57-
});
58-
78+
expect(screen.getByRole('status')).toBeInTheDocument();
5979
expect(screen.getByText(/You are offline/i)).toBeInTheDocument();
6080
});
6181

62-
it('should show reconnecting state when coming back online', async () => {
63-
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
82+
it('should show reconnecting state when coming back online', () => {
6483
let isOnline = false;
65-
66-
(useOnlineStatus as any).mockImplementation(() => ({
67-
get isOnline() { return isOnline; },
68-
wasOffline: true,
69-
resetWasOffline: vi.fn(),
70-
}));
84+
stubOnlineStatus(() => isOnline, true);
7185

7286
const { rerender } = render(<OfflineStatusBanner />);
73-
74-
await waitFor(() => {
75-
expect(screen.getByText(/You are offline/i)).toBeInTheDocument();
76-
});
87+
settleLoadingGate();
88+
expect(screen.getByText(/You are offline/i)).toBeInTheDocument();
7789

7890
// Simulate coming back online
7991
isOnline = true;
8092
rerender(<OfflineStatusBanner />);
8193

82-
await waitFor(() => {
83-
expect(screen.getByText(/Reconnecting/i)).toBeInTheDocument();
84-
});
94+
expect(screen.getByText(/Reconnecting/i)).toBeInTheDocument();
8595
});
8696

87-
it('should display optimistic pending count', async () => {
88-
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
89-
(useOnlineStatus as any).mockReturnValue({
90-
isOnline: false,
91-
wasOffline: false,
92-
resetWasOffline: vi.fn(),
97+
it('should display optimistic pending count', () => {
98+
stubOnlineStatus(() => false);
99+
// The count is pushed by `offlineMessageQueue` subscribers, not pulled.
100+
mockedSubscribe.mockImplementation((listener) => {
101+
listener(3);
102+
return () => {};
93103
});
94104

95-
(offlineMessageQueue.getQueuedMessageCount as any).mockReturnValue(3);
96-
97105
render(<OfflineStatusBanner />);
98106

99-
await waitFor(() => {
100-
expect(screen.getByText(/3 messages waiting to send/i)).toBeInTheDocument();
107+
expect(screen.getByText(/3 messages waiting to send/i)).toBeInTheDocument();
108+
});
109+
110+
it('should pluralise a single pending message', () => {
111+
stubOnlineStatus(() => false);
112+
mockedSubscribe.mockImplementation((listener) => {
113+
listener(1);
114+
return () => {};
101115
});
116+
117+
render(<OfflineStatusBanner />);
118+
119+
expect(screen.getByText(/1 message waiting to send/i)).toBeInTheDocument();
102120
});
103121

104-
it('should hide banner after reconnection delay', async () => {
105-
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
122+
it('should hide banner after reconnection delay', () => {
106123
let isOnline = false;
107-
108-
(useOnlineStatus as any).mockImplementation(() => ({
109-
get isOnline() { return isOnline; },
110-
wasOffline: true,
111-
resetWasOffline: vi.fn(),
112-
}));
124+
const resetWasOffline = stubOnlineStatus(() => isOnline, true);
113125

114126
const { rerender } = render(<OfflineStatusBanner />);
115-
116-
await waitFor(() => {
117-
expect(screen.getByRole('status')).toBeInTheDocument();
118-
});
127+
settleLoadingGate();
128+
expect(screen.getByRole('status')).toBeInTheDocument();
119129

120130
// Simulate coming back online
121131
isOnline = true;
122132
rerender(<OfflineStatusBanner />);
133+
expect(screen.getByRole('status')).toBeInTheDocument();
123134

124135
act(() => {
125136
vi.advanceTimersByTime(500);
126137
});
127138

128-
await waitFor(() => {
129-
expect(screen.queryByRole('status')).not.toBeInTheDocument();
130-
});
139+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
140+
expect(resetWasOffline).toHaveBeenCalled();
131141
});
132142

133-
it('should update aria-label based on connection state', async () => {
134-
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
143+
it('should update aria-label based on connection state', () => {
135144
let isOnline = false;
136-
137-
(useOnlineStatus as any).mockImplementation(() => ({
138-
get isOnline() { return isOnline; },
139-
wasOffline: true,
140-
resetWasOffline: vi.fn(),
141-
}));
145+
stubOnlineStatus(() => isOnline, true);
142146

143147
const { rerender } = render(<OfflineStatusBanner />);
144-
145-
await waitFor(() => {
146-
expect(screen.getByLabelText('Offline status')).toBeInTheDocument();
147-
});
148+
settleLoadingGate();
149+
expect(screen.getByLabelText('Offline status')).toBeInTheDocument();
148150

149151
isOnline = true;
150152
rerender(<OfflineStatusBanner />);
151153

152-
await waitFor(() => {
153-
expect(screen.getByLabelText('Reconnecting')).toBeInTheDocument();
154-
});
154+
expect(screen.getByLabelText('Reconnecting')).toBeInTheDocument();
155155
});
156156

157-
it('should show loading skeleton initially when online', async () => {
158-
const { useOnlineStatus } = await import('@/hooks/useOnlineStatus');
159-
(useOnlineStatus as any).mockReturnValue({
160-
isOnline: true,
161-
wasOffline: false,
162-
resetWasOffline: vi.fn(),
163-
});
157+
it('should show loading skeleton initially when online', () => {
158+
stubOnlineStatus(() => true);
164159

165160
render(<OfflineStatusBanner />);
166161

167162
// Should show loading skeleton initially
168-
const skeleton = document.querySelector('[aria-hidden="true"]');
169-
expect(skeleton).toBeInTheDocument();
163+
expect(document.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
170164

171165
act(() => {
172166
vi.advanceTimersByTime(300);
173167
});
174168

175-
await waitFor(() => {
176-
expect(document.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument();
177-
});
169+
expect(
170+
document.querySelector('[aria-hidden="true"]'),
171+
).not.toBeInTheDocument();
178172
});
179173
});

0 commit comments

Comments
 (0)