Skip to content

Commit 5bb0dc0

Browse files
committed
Merge branch 'feat/add_request_validation' of https://github.com/Anthony-19/Stellar-Dex-Chat into feat/add_request_validation
2 parents e94e74f + c738f14 commit 5bb0dc0

18 files changed

Lines changed: 1673 additions & 134 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
)}

Dechat/dex_with_fiat_frontend/src/components/OfflineStatusBanner.test.tsx

Lines changed: 75 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { act } from '@testing-library/react';
33
import { render, screen } from '@testing-library/react';
44
import OfflineStatusBanner from './OfflineStatusBanner';
5-
import * as offlineMessageQueue from '@/lib/offlineMessageQueue';
5+
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
6+
import { subscribeToQueuedMessageCount } from '@/lib/offlineMessageQueue';
67

78
// Mock dependencies
89
vi.mock('@/hooks/useOnlineStatus', () => ({
@@ -26,14 +27,42 @@ vi.mock('@/lib/offlineStatusSchema', () => ({
2627
}));
2728

2829
vi.mock('@/lib/offlineMessageQueue', () => ({
29-
subscribeToQueuedMessageCount: vi.fn(),
30+
subscribeToQueuedMessageCount: vi.fn(() => () => {}),
3031
setQueuedMessageCount: vi.fn(),
3132
getQueuedMessageCount: vi.fn(() => 0),
3233
}));
3334

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

@@ -42,138 +71,104 @@ describe('OfflineStatusBanner - Optimistic UI Updates', () => {
4271
vi.useRealTimers();
4372
});
4473

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-
});
74+
it('should show banner immediately when going offline', () => {
75+
stubOnlineStatus(() => false);
5276

5377
render(<OfflineStatusBanner />);
5478

55-
await waitFor(() => {
56-
expect(screen.getByRole('status')).toBeInTheDocument();
57-
});
58-
79+
expect(screen.getByRole('status')).toBeInTheDocument();
5980
expect(screen.getByText(/You are offline/i)).toBeInTheDocument();
6081
});
6182

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

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

7891
// Simulate coming back online
7992
isOnline = true;
8093
rerender(<OfflineStatusBanner />);
8194

82-
await waitFor(() => {
83-
expect(screen.getByText(/Reconnecting/i)).toBeInTheDocument();
84-
});
95+
expect(screen.getByText(/Reconnecting/i)).toBeInTheDocument();
8596
});
8697

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(),
98+
it('should display optimistic pending count', () => {
99+
stubOnlineStatus(() => false);
100+
// The count is pushed by `offlineMessageQueue` subscribers, not pulled.
101+
mockedSubscribe.mockImplementation((listener) => {
102+
listener(3);
103+
return () => {};
93104
});
94105

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

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

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

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

120131
// Simulate coming back online
121132
isOnline = true;
122133
rerender(<OfflineStatusBanner />);
134+
expect(screen.getByRole('status')).toBeInTheDocument();
123135

124136
act(() => {
125137
vi.advanceTimersByTime(500);
126138
});
127139

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

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

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

149152
isOnline = true;
150153
rerender(<OfflineStatusBanner />);
151154

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

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-
});
158+
it('should show loading skeleton initially when online', () => {
159+
stubOnlineStatus(() => true);
164160

165161
render(<OfflineStatusBanner />);
166162

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

171166
act(() => {
172167
vi.advanceTimersByTime(300);
173168
});
174169

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

0 commit comments

Comments
 (0)