From bcfbcd8604ef825d9c0c0f425bb70e49509ce77c Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Mon, 14 Sep 2026 12:29:21 +0700 Subject: [PATCH 1/3] Preserve mobile chat drafts and bound delivery waits --- apps/mobile/README.md | 19 ++ apps/mobile/app/(app)/session/[id].tsx | 6 + apps/mobile/src/components/ChatPanel.test.tsx | 283 ++++++++++++++++++ apps/mobile/src/components/ChatPanel.tsx | 132 +++++++- .../mobile/src/hooks/useChat-delivery.test.ts | 83 +++++ apps/mobile/src/hooks/useChat.test.ts | 11 +- apps/mobile/src/hooks/useChat.ts | 49 ++- apps/mobile/src/lib/api.ts | 17 +- apps/mobile/src/lib/api/chat-delivery.test.ts | 87 ++++++ apps/mobile/src/lib/api/chat.ts | 63 +++- 10 files changed, 712 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/components/ChatPanel.test.tsx create mode 100644 apps/mobile/src/hooks/useChat-delivery.test.ts create mode 100644 apps/mobile/src/lib/api/chat-delivery.test.ts diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 6a2f9e32..727ab77e 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -76,6 +76,25 @@ those prompt-owned transitions are ignored while a real background transition st the resume grace period. A brief iOS `inactive` transition alone does not tear down the call or screen share. +## Chat delivery + +Chat reads and sends have a 15-second waiting deadline, including authentication +and response parsing. Leaving a room, disabling chat or unmounting cancels active +requests. This does not undo a message the server may already have saved. + +Known rejections keep the draft. Timeouts and interrupted sends warn that delivery +is unknown; sending again is a manual choice and can duplicate the message. Newer +typing is never overwritten: a separate saved-draft block must be restored into an +empty input or explicitly discarded before another send. Drafts are in memory, +not persisted after leaving the session. There is no automatic resend or claim of +exactly-once delivery. Messages with known server IDs are deduplicated as before. + +The collapsed badge counts newly received messages, excluding own messages and +initial history. Opening the panel clears the local badge; this is not a shared +read receipt. Tests cover hooks, the panel through DOM/native callback adapters, +and bounded transport behavior; they do not certify native keyboard or layout +behavior on an actual iOS/Android device. + ## EAS builds Link the app to the intended Expo project and provide its UUID through `EAS_PROJECT_ID`. No diff --git a/apps/mobile/app/(app)/session/[id].tsx b/apps/mobile/app/(app)/session/[id].tsx index 21f8bc8e..bafcc39b 100644 --- a/apps/mobile/app/(app)/session/[id].tsx +++ b/apps/mobile/app/(app)/session/[id].tsx @@ -260,11 +260,14 @@ function HostSession({ {/* Chat */} ); @@ -374,11 +377,14 @@ function ViewerSession({ {/* Chat */} ); diff --git a/apps/mobile/src/components/ChatPanel.test.tsx b/apps/mobile/src/components/ChatPanel.test.tsx new file mode 100644 index 00000000..dfe42b06 --- /dev/null +++ b/apps/mobile/src/components/ChatPanel.test.tsx @@ -0,0 +1,283 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ChatPanel } from './ChatPanel'; +import type { ChatMessage as Message } from '@pairux/shared-types'; +import { useChat } from '../hooks/useChat'; +import { chatApi } from '../lib/api/chat'; +vi.mock('../lib/api/chat'); + +// DOM adapters expose native callbacks; the actual panel logic is rendered. +vi.mock('react-native', async () => { + const R = await import('react'); + const view = ({ children }: { children?: React.ReactNode }) => + R.createElement('div', {}, children); + return { + View: view, + Text: ({ children }: { children?: React.ReactNode }) => R.createElement('span', {}, children), + KeyboardAvoidingView: view, + Platform: { OS: 'ios' }, + TouchableOpacity: ({ + children, + onPress, + disabled, + accessibilityLabel, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + accessibilityLabel?: string; + }) => + R.createElement( + 'button', + { onClick: onPress, disabled, 'aria-label': accessibilityLabel }, + children + ), + TextInput: ({ + value, + onChangeText, + placeholder, + }: { + value: string; + onChangeText: (text: string) => void; + placeholder: string; + }) => + R.createElement('input', { + value, + placeholder, + onChange: (e: React.ChangeEvent) => onChangeText(e.target.value), + }), + FlatList: R.forwardRef( + ( + { + data, + renderItem, + ListEmptyComponent, + }: { + data: Message[]; + renderItem: (args: { item: Message }) => React.ReactNode; + ListEmptyComponent?: React.ReactNode; + }, + ref + ) => { + R.useImperativeHandle(ref, () => ({ scrollToEnd() {} })); + return R.createElement( + 'div', + {}, + data.length + ? data.map((item) => + R.createElement(R.Fragment, { key: item.id }, renderItem({ item })) + ) + : ListEmptyComponent + ); + } + ), + Animated: { + View: view, + Value: class { + setValue() {} + }, + timing: () => ({ start() {}, stop() {} }), + }, + }; +}); +vi.mock('./ChatMessage', () => ({ + ChatMessage: ({ message }: { message: Message }) => {message.content}, +})); +const message = (id: string, user = 'other'): Message => ({ + id, + session_id: 'room', + user_id: user, + display_name: 'Example', + content: id, + message_type: 'text', + created_at: '2026-09-14T00:00:00Z', + recipient_id: null, +}); +const input = () => screen.getByPlaceholderText('Type a message...'); +const open = () => fireEvent.click(screen.getByText('Chat')); + +describe('chat draft recovery', () => { + it.each(['cancelled', 'ignored'])( + 'restores a draft when send is %s before transport', + async (status) => { + const onSend = vi.fn().mockResolvedValue({ status }); + render(); + open(); + fireEvent.change(input(), { target: { value: 'unsent text' } }); + fireEvent.click(screen.getByText('Send')); + await waitFor(() => expect(input().value).toBe('unsent text')); + expect(screen.getByText(/Message not sent/)).toBeDefined(); + } + ); + it('keeps a draft after the server rejects a message', async () => { + const onSend = vi.fn().mockResolvedValue({ status: 'failed' }); + render(); + open(); + fireEvent.change(input(), { target: { value: 'keep my text' } }); + fireEvent.click(screen.getByText('Send')); + await waitFor(() => expect(input().value).toBe('keep my text')); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it('keeps delivery uncertainty visible after a timeout and never auto-resends', async () => { + const onSend = vi.fn().mockResolvedValue({ status: 'unknown' }); + render(); + open(); + fireEvent.change(input(), { target: { value: 'maybe sent' } }); + fireEvent.click(screen.getByText('Send')); + await waitFor(() => expect(screen.getByText(/may have been sent/i)).toBeDefined()); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it('does not overwrite newer typing with the failed previous message', async () => { + let resolve!: (result: { status: 'failed' }) => void; + const onSend = vi.fn().mockReturnValue( + new Promise((r) => { + resolve = r; + }) + ); + render(); + open(); + fireEvent.change(input(), { target: { value: 'old draft' } }); + fireEvent.click(screen.getByText('Send')); + fireEvent.change(input(), { target: { value: 'new draft' } }); + await act(async () => { + resolve({ status: 'failed' }); + }); + expect(input().value).toBe('new draft'); + expect(screen.getByText(/old draft/)).toBeDefined(); + expect(screen.getByRole('button', { name: 'Send' }).hasAttribute('disabled')).toBe(true); + expect(screen.getByLabelText('Restore saved draft').hasAttribute('disabled')).toBe(true); + fireEvent.change(input(), { target: { value: '' } }); + fireEvent.click(screen.getByLabelText('Restore saved draft')); + expect(input().value).toBe('old draft'); + expect(screen.queryByLabelText('Restore saved draft')).toBeNull(); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it('guards rapid double sends even before the parent rerenders', async () => { + const onSend = vi.fn().mockReturnValue(new Promise(() => {})); + render(); + open(); + fireEvent.change(input(), { target: { value: 'first' } }); + fireEvent.click(screen.getByText('Send')); + fireEvent.change(input(), { target: { value: 'second' } }); + fireEvent.click(screen.getByText('Send')); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it('does not count initial history, own messages, or messages read while open', () => { + const props = { + onSend: vi.fn(), + sending: false, + currentUserId: 'me', + loading: false, + historyReady: true, + }; + const { rerender } = render(); + expect(screen.queryByText('1')).toBeNull(); + rerender(); + expect(screen.getByText('1')).toBeDefined(); + rerender( + + ); + expect(screen.getByText('1')).toBeDefined(); + open(); + open(); + expect(screen.queryByText('3')).toBeNull(); + expect(screen.queryByText('1')).toBeNull(); + }); + + it('baselines the first successful history after a failed attempt, not as unread', () => { + const props = { onSend: vi.fn(), sending: false, currentUserId: 'me', loading: false }; + const { rerender } = render( + + ); + rerender(); + expect(screen.queryByText('1')).toBeNull(); + rerender(); + expect(screen.getByText('1')).toBeDefined(); + }); + + it('does not count messages before the current user is known', () => { + const props = { onSend: vi.fn(), sending: false, historyReady: true }; + const { rerender } = render(); + rerender(); + expect(screen.queryByText('1')).toBeNull(); + rerender(); + rerender( + + ); + expect(screen.getByText('1')).toBeDefined(); + }); +}); + +function SessionChat({ + sessionId = 'room', + enabled = true, +}: { + sessionId?: string; + enabled?: boolean; +}) { + const chat = useChat({ sessionId, enabled }); + return ( + + ); +} + +describe('panel and hook lifecycle together', () => { + it('restores an in-flight draft with an uncertainty warning when chat is disabled', async () => { + vi.mocked(chatApi.getHistory).mockResolvedValue({ data: { messages: [], hasMore: false } }); + vi.mocked(chatApi.send).mockImplementationOnce( + (_room, _content, _participant, signal) => + new Promise((resolve) => { + signal?.addEventListener( + 'abort', + () => resolve({ error: 'Aborted', failureKind: 'unknown' }), + { once: true } + ); + }) + ); + const { rerender } = render(); + open(); + fireEvent.change(input(), { target: { value: 'pending draft' } }); + fireEvent.click(screen.getByText('Send')); + rerender(); + await waitFor(() => expect(input().value).toBe('pending draft')); + expect(screen.getByText(/may have been sent/)).toBeDefined(); + expect(chatApi.send).toHaveBeenCalledTimes(1); + }); + + it('does not put a late old-room response or draft into a new room', async () => { + vi.mocked(chatApi.getHistory).mockResolvedValue({ data: { messages: [], hasMore: false } }); + let complete!: (value: { data: Message }) => void; + vi.mocked(chatApi.send).mockReturnValueOnce( + new Promise((resolve) => { + complete = resolve; + }) + ); + const { rerender } = render(); + open(); + fireEvent.change(input(), { target: { value: 'old room secret' } }); + fireEvent.click(screen.getByText('Send')); + rerender(); + open(); + fireEvent.change(input(), { target: { value: 'new room draft' } }); + await act(async () => { + complete({ data: { ...message('old'), content: 'old room secret' } }); + }); + expect(input().value).toBe('new room draft'); + expect(screen.queryByText('old room secret')).toBeNull(); + expect(screen.queryByText(/may have been sent/)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/ChatPanel.tsx b/apps/mobile/src/components/ChatPanel.tsx index 25f8f123..439e0594 100644 --- a/apps/mobile/src/components/ChatPanel.tsx +++ b/apps/mobile/src/components/ChatPanel.tsx @@ -14,20 +14,66 @@ import { } from 'react-native'; import type { ChatMessage as ChatMessageType } from '@pairux/shared-types'; import { ChatMessage } from './ChatMessage'; +import type { ChatSendOutcome } from '../hooks/useChat'; interface ChatPanelProps { messages: ChatMessageType[]; - onSend: (content: string) => Promise; + onSend: (content: string) => Promise; sending: boolean; currentUserId?: string; loading?: boolean; + historyReady?: boolean; + error?: string | null; } -export function ChatPanel({ messages, onSend, sending, currentUserId, loading }: ChatPanelProps) { +export function ChatPanel({ + messages, + onSend, + sending, + currentUserId, + loading, + historyReady = false, + error, +}: ChatPanelProps) { const [expanded, setExpanded] = useState(false); const [input, setInput] = useState(''); const flatListRef = useRef>(null); const panelHeight = useRef(new Animated.Value(48)).current; + const inputRef = useRef(''); + const busyRef = useRef(false); + const mountedRef = useRef(true); + const [localSending, setLocalSending] = useState(false); + const [notice, setNotice] = useState(null); + const [savedDraft, setSavedDraft] = useState(null); + const [unreadCount, setUnreadCount] = useState(0); + const seenRef = useRef(new Set()); + const initializedRef = useRef(false); + + const changeInput = (text: string) => { + inputRef.current = text; + setInput(text); + }; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + const fresh = messages.filter((message) => !seenRef.current.has(message.id)); + for (const message of messages) seenRef.current.add(message.id); + if (!initializedRef.current) { + if (historyReady && currentUserId) initializedRef.current = true; + return; + } + if (expanded) setUnreadCount(0); + else if (currentUserId) + setUnreadCount( + (count) => count + fresh.filter((message) => message.user_id !== currentUserId).length + ); + }, [messages, historyReady, expanded, currentUserId]); useEffect(() => { Animated.timing(panelHeight, { @@ -40,21 +86,44 @@ export function ChatPanel({ messages, onSend, sending, currentUserId, loading }: // Auto-scroll to bottom when new messages arrive useEffect(() => { if (expanded && messages.length > 0) { - setTimeout(() => { + const timer = setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); + return () => { + clearTimeout(timer); + }; } + return undefined; }, [messages.length, expanded]); async function handleSend() { - if (!input.trim() || sending) return; - const content = input; - setInput(''); - await onSend(content); + if (!inputRef.current.trim() || sending || busyRef.current || savedDraft) return; + const content = inputRef.current; + busyRef.current = true; + setLocalSending(true); + changeInput(''); + let outcome: ChatSendOutcome; + try { + outcome = await onSend(content); + } catch { + outcome = { status: 'unknown' }; + } + if (!mountedRef.current) return; + busyRef.current = false; + setLocalSending(false); + if (outcome.status === 'sent') { + setNotice(null); + return; + } + if (!inputRef.current.trim()) changeInput(content); + else setSavedDraft(content); + setNotice( + outcome.status === 'unknown' + ? 'This message may have been sent. Resending can duplicate it.' + : 'Message not sent. Your draft has been kept.' + ); } - const unreadCount = expanded ? 0 : messages.length; - return ( {/* Header toggle */} @@ -99,11 +168,48 @@ export function ChatPanel({ messages, onSend, sending, currentUserId, loading }: )} /> + {(notice ?? error) && ( + + {notice ?? 'Chat could not be updated. It will check again shortly.'} + + )} + {savedDraft && ( + + + Sending paused: another draft is saved. + + + {savedDraft} + + { + if (inputRef.current.trim()) return; + changeInput(savedDraft); + setSavedDraft(null); + }} + accessibilityLabel="Restore saved draft" + > + + Restore draft + + + { + setSavedDraft(null); + setNotice(null); + }} + accessibilityLabel="Discard saved draft" + > + Discard saved draft + + + )} {/* Input */} { void handleSend(); }} - disabled={!input.trim() || sending} + disabled={!input.trim() || sending || localSending || !!savedDraft} className={`rounded-full px-4 py-2 ${ - !input.trim() || sending ? 'bg-gray-700' : 'bg-primary-600' + !input.trim() || sending || localSending || savedDraft + ? 'bg-gray-700' + : 'bg-primary-600' }`} > Send diff --git a/apps/mobile/src/hooks/useChat-delivery.test.ts b/apps/mobile/src/hooks/useChat-delivery.test.ts new file mode 100644 index 00000000..0880eb51 --- /dev/null +++ b/apps/mobile/src/hooks/useChat-delivery.test.ts @@ -0,0 +1,83 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useChat } from './useChat'; +import { chatApi } from '../lib/api/chat'; +vi.mock('../lib/api/chat'); + +describe('chat delivery outcomes', () => { + it('marks history ready only after success and resets it for a different room', async () => { + vi.mocked(chatApi.getHistory).mockResolvedValueOnce({ error: 'Offline' }); + const { result, rerender } = renderHook( + ({ sessionId, enabled }) => useChat({ sessionId, enabled }), + { + initialProps: { sessionId: 'room', enabled: true }, + } + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.historyReady).toBe(false); + rerender({ sessionId: 'room', enabled: false }); + vi.mocked(chatApi.getHistory).mockResolvedValueOnce({ data: { messages: [], hasMore: false } }); + rerender({ sessionId: 'room', enabled: true }); + await waitFor(() => expect(result.current.historyReady).toBe(true)); + rerender({ sessionId: 'other-room', enabled: false }); + expect(result.current.historyReady).toBe(false); + }); + + it.each([ + [{ error: 'Rejected', failureKind: 'rejected' as const }, 'failed'], + [{ error: 'Network failure' }, 'unknown'], + ])('distinguishes rejection from uncertainty: %j', async (response, status) => { + vi.mocked(chatApi.getHistory).mockResolvedValue({ data: { messages: [], hasMore: false } }); + vi.mocked(chatApi.send).mockResolvedValue(response); + const { result } = renderHook(() => useChat({ sessionId: 'room' })); + await waitFor(() => expect(result.current.loading).toBe(false)); + let outcome: unknown; + await act(async () => { + outcome = await result.current.sendMessage('test'); + }); + expect(outcome).toEqual({ status }); + }); + + it('cancels before sending when disabled', async () => { + vi.mocked(chatApi.send).mockClear(); + const { result } = renderHook(() => useChat({ sessionId: 'room', enabled: false })); + expect(await result.current.sendMessage('draft')).toEqual({ status: 'cancelled' }); + expect(chatApi.send).not.toHaveBeenCalled(); + }); + + it('keeps an aborted send uncertain and can send again after re-enabling', async () => { + vi.mocked(chatApi.getHistory).mockResolvedValue({ data: { messages: [], hasMore: false } }); + vi.mocked(chatApi.send).mockImplementationOnce( + (_room, _text, _participant, signal) => + new Promise((resolve) => { + signal?.addEventListener( + 'abort', + () => resolve({ error: 'Aborted', failureKind: 'unknown' }), + { once: true } + ); + }) + ); + const { result, rerender } = renderHook( + ({ enabled }) => useChat({ sessionId: 'room', enabled }), + { + initialProps: { enabled: true }, + } + ); + let pending!: ReturnType; + act(() => { + pending = result.current.sendMessage('once'); + }); + expect(result.current.sending).toBe(true); + rerender({ enabled: false }); + await act(async () => { + expect(await pending).toEqual({ status: 'unknown' }); + }); + expect(result.current.sending).toBe(false); + rerender({ enabled: true }); + vi.mocked(chatApi.send).mockResolvedValue({ error: 'Rejected', failureKind: 'rejected' }); + await act(async () => { + expect(await result.current.sendMessage('manual retry')).toEqual({ status: 'failed' }); + }); + expect(result.current.sending).toBe(false); + }); +}); diff --git a/apps/mobile/src/hooks/useChat.test.ts b/apps/mobile/src/hooks/useChat.test.ts index 21398e72..cf8d56c2 100644 --- a/apps/mobile/src/hooks/useChat.test.ts +++ b/apps/mobile/src/hooks/useChat.test.ts @@ -76,7 +76,12 @@ describe('useChat', () => { await result.current.sendMessage('Hello world'); }); - expect(chatApi.send).toHaveBeenCalledWith('session-1', 'Hello world', 'participant-1'); + expect(chatApi.send).toHaveBeenCalledWith( + 'session-1', + 'Hello world', + 'participant-1', + expect.any(AbortSignal) + ); expect(result.current.messages).toHaveLength(1); }); @@ -225,7 +230,7 @@ describe('useChat', () => { ); await waitFor(() => expect(result.current.loading).toBe(false)); - let sendPromise!: Promise; + let sendPromise!: ReturnType; act(() => { sendPromise = result.current.sendMessage('old session message'); }); @@ -258,7 +263,7 @@ describe('useChat', () => { ); await waitFor(() => expect(result.current.loading).toBe(false)); - let sendPromise!: Promise; + let sendPromise!: ReturnType; act(() => { sendPromise = result.current.sendMessage('message before disable'); }); diff --git a/apps/mobile/src/hooks/useChat.ts b/apps/mobile/src/hooks/useChat.ts index e0dbb7c6..12b5b747 100644 --- a/apps/mobile/src/hooks/useChat.ts +++ b/apps/mobile/src/hooks/useChat.ts @@ -16,11 +16,16 @@ interface UseChatOptions { enabled?: boolean; } +export interface ChatSendOutcome { + status: 'sent' | 'failed' | 'unknown' | 'cancelled' | 'ignored'; +} + interface UseChatReturn { messages: ChatMessage[]; loading: boolean; + historyReady: boolean; error: string | null; - sendMessage: (content: string) => Promise; + sendMessage: (content: string) => Promise; sending: boolean; } @@ -31,6 +36,7 @@ export function useChat({ }: UseChatOptions): UseChatReturn { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); + const [historyReady, setHistoryReady] = useState(false); const [error, setError] = useState(null); const [sending, setSending] = useState(false); @@ -40,16 +46,20 @@ export function useChat({ const pollInFlightRef = useRef(null); const sendOperationRef = useRef(null); const mountedRef = useRef(true); + const isMounted = useCallback(() => mountedRef.current, []); const previousSessionIdRef = useRef(sessionId); + const requestsRef = useRef(new Set()); // Fetch messages const fetchMessages = useCallback( async (generation: number) => { if (pollInFlightRef.current === generation) return; pollInFlightRef.current = generation; + const controller = new AbortController(); + requestsRef.current.add(controller); try { - const result = await chatApi.getHistory(sessionId, { limit: 100 }); + const result = await chatApi.getHistory(sessionId, { limit: 100 }, controller.signal); if (!mountedRef.current || pollGenerationRef.current !== generation) return; if (result.error) { @@ -78,12 +88,14 @@ export function useChat({ } setError(null); + setHistoryReady(true); } } catch { if (mountedRef.current && pollGenerationRef.current === generation) { setError('Failed to fetch messages'); } } finally { + requestsRef.current.delete(controller); if (pollInFlightRef.current === generation) { pollInFlightRef.current = null; } @@ -98,6 +110,7 @@ export function useChat({ // Start polling when enabled useEffect(() => { const generation = ++pollGenerationRef.current; + const requests = requestsRef.current; const sessionChanged = previousSessionIdRef.current !== sessionId; previousSessionIdRef.current = sessionId; @@ -111,6 +124,7 @@ export function useChat({ if (sessionChanged) { seenIdsRef.current = new Set(); setMessages([]); + setHistoryReady(false); setError(null); setLoading(true); } @@ -128,6 +142,8 @@ export function useChat({ }, POLL_INTERVAL); return () => { + for (const controller of requests) controller.abort(); + requests.clear(); if (pollGenerationRef.current === generation) { pollGenerationRef.current += 1; } @@ -140,45 +156,55 @@ export function useChat({ // Send message const sendMessage = useCallback( - async (content: string) => { + async (content: string): Promise => { const trimmed = content.trim(); - if (!trimmed || sendOperationRef.current) return; + if (!trimmed || sendOperationRef.current) return { status: 'ignored' }; + if (!enabled || !isMounted()) return { status: 'cancelled' }; const generation = pollGenerationRef.current; const operation = Symbol('chat-send'); sendOperationRef.current = operation; setSending(true); + const controller = new AbortController(); + requestsRef.current.add(controller); try { - const result = await chatApi.send(sessionId, trimmed, participantId); - if (!mountedRef.current || pollGenerationRef.current !== generation) return; + const result = await chatApi.send(sessionId, trimmed, participantId, controller.signal); + if (!isMounted() || pollGenerationRef.current !== generation) return { status: 'unknown' }; if (result.error) { setError(result.error); - return; + return { status: result.failureKind === 'rejected' ? 'failed' : 'unknown' }; } const msg = result.data; - if (msg && !seenIdsRef.current.has(msg.id)) { + if (!msg || typeof msg.id !== 'string' || !msg.id || msg.session_id !== sessionId) { + setError('Message delivery could not be confirmed'); + return { status: 'unknown' }; + } + if (!seenIdsRef.current.has(msg.id)) { seenIdsRef.current.add(msg.id); setMessages((prev) => [...prev, msg]); } setError(null); + return { status: 'sent' }; } catch { - if (mountedRef.current && pollGenerationRef.current === generation) { + if (isMounted() && pollGenerationRef.current === generation) { setError('Failed to send message'); } + return { status: 'unknown' }; } finally { + requestsRef.current.delete(controller); if (sendOperationRef.current === operation) { sendOperationRef.current = null; - if (mountedRef.current && pollGenerationRef.current === generation) { + if (isMounted()) { setSending(false); } } } }, - [sessionId, participantId] + [sessionId, participantId, enabled, isMounted] ); useEffect(() => { @@ -193,6 +219,7 @@ export function useChat({ return { messages, loading, + historyReady, error, sendMessage, sending, diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 9e1d2fa2..a9f314bd 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -13,6 +13,7 @@ import { getValidAccessToken } from './auth-session'; export interface ApiResponse { data?: T; error?: string; + failureKind?: 'rejected' | 'unknown'; } export async function getAuthToken(): Promise { @@ -34,11 +35,13 @@ export async function apiRequest( if (requireAuth) { const token = await getAuthToken(); if (!token) { - return { error: 'Not authenticated' }; + return { error: 'Not authenticated', failureKind: 'rejected' }; } headers.Authorization = `Bearer ${token}`; } + // An auth refresh may finish after the caller has abandoned its request. + if (options.signal?.aborted) return { error: 'Request cancelled', failureKind: 'unknown' }; const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, @@ -47,11 +50,19 @@ export async function apiRequest( const data = (await response.json()) as ApiResponse; if (!response.ok) { - return { error: data.error ?? `Request failed with status ${String(response.status)}` }; + return { + error: data.error ?? `Request failed with status ${String(response.status)}`, + failureKind: [400, 401, 403, 404, 413, 422, 429].includes(response.status) + ? 'rejected' + : 'unknown', + }; } return data; } catch (error) { - return { error: error instanceof Error ? error.message : 'Network error' }; + return { + error: error instanceof Error ? error.message : 'Network error', + failureKind: 'unknown', + }; } } diff --git a/apps/mobile/src/lib/api/chat-delivery.test.ts b/apps/mobile/src/lib/api/chat-delivery.test.ts new file mode 100644 index 00000000..d48f003c --- /dev/null +++ b/apps/mobile/src/lib/api/chat-delivery.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { chatApi, CHAT_REQUEST_TIMEOUT_MS } from './chat'; +import { getAuthToken } from '../api'; +import { getValidAccessToken } from '../auth-session'; +vi.mock('../auth-session', () => ({ + getValidAccessToken: vi.fn().mockResolvedValue('test-token'), +})); + +describe('bounded chat requests', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(fetch).mockReset(); + }); + afterEach(() => vi.useRealTimers()); + + it('finishes waiting even if native fetch ignores abort and never resolves', async () => { + vi.mocked(fetch).mockImplementation(() => new Promise(() => {})); + const pending = chatApi.send('room', 'once'); + await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS); + expect(await pending).toMatchObject({ failureKind: 'unknown' }); + expect(fetch).toHaveBeenCalledTimes(1); + const init = vi.mocked(fetch).mock.calls[0]?.[1]; + expect(init?.signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it('bounds a response whose body never finishes parsing', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, json: () => new Promise(() => {}) } as Response); + const pending = chatApi.send('room', 'once'); + await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS); + expect(await pending).toMatchObject({ failureKind: 'unknown' }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not send a request that was cancelled before it starts', async () => { + const controller = new AbortController(); + controller.abort(); + expect(await chatApi.send('room', 'unused', undefined, controller.signal)).toMatchObject({ + failureKind: 'unknown', + }); + expect(fetch).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('never sends a late POST if authentication finishes after the deadline', async () => { + let finishAuth!: (value: string) => void; + vi.mocked(getValidAccessToken).mockReturnValueOnce( + new Promise((resolve) => { + finishAuth = resolve; + }) + ); + const pending = chatApi.send('room', 'must not be posted later'); + await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS); + expect(await pending).toMatchObject({ failureKind: 'unknown' }); + finishAuth('late-token'); + await vi.advanceTimersByTimeAsync(1); + expect(fetch).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([400, 401, 403, 404, 413, 422, 429])( + 'classifies explicit %s rejection without retry', + async (status) => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status, + json: async () => ({ error: 'Rejected' }), + } as Response); + expect(await chatApi.send('room', 'test')).toMatchObject({ failureKind: 'rejected' }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } + ); + + it.each([408, 500, 502, 504])('keeps ambiguous %s failure unknown', async (status) => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status, + json: async () => ({ error: 'Uncertain' }), + } as Response); + expect(await chatApi.send('room', 'test')).toMatchObject({ failureKind: 'unknown' }); + }); + + it('still uses the existing auth flow', async () => { + expect(await getAuthToken()).toBe('test-token'); + }); +}); diff --git a/apps/mobile/src/lib/api/chat.ts b/apps/mobile/src/lib/api/chat.ts index 4418c5b7..3c40378f 100644 --- a/apps/mobile/src/lib/api/chat.ts +++ b/apps/mobile/src/lib/api/chat.ts @@ -4,23 +4,68 @@ * Port of apps/desktop/src/renderer/lib/api.ts chatApi. */ import type { ChatMessage } from '@pairux/shared-types'; -import { apiRequest } from '../api'; +import { apiRequest, type ApiResponse } from '../api'; + +export const CHAT_REQUEST_TIMEOUT_MS = 15_000; + +// Bound the entire operation, including auth and body parsing. Some native +// fetch implementations can settle late even after abort, so race explicitly. +async function chatRequest( + endpoint: string, + options: RequestInit, + signal?: AbortSignal +): Promise> { + const controller = new AbortController(); + let timer: ReturnType | undefined; + let cancel!: () => void; + const interrupted = new Promise>((resolve) => { + cancel = () => { + controller.abort(); + resolve({ error: 'Chat request interrupted', failureKind: 'unknown' }); + }; + timer = setTimeout(cancel, CHAT_REQUEST_TIMEOUT_MS); + signal?.addEventListener('abort', cancel, { once: true }); + }); + try { + if (signal?.aborted) { + cancel(); + return await interrupted; + } + return await Promise.race([ + apiRequest(endpoint, { ...options, signal: controller.signal }), + interrupted, + ]); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', cancel); + } +} export const chatApi = { - async send(sessionId: string, content: string, participantId?: string) { - return apiRequest('/api/chat/send', { - method: 'POST', - body: JSON.stringify({ sessionId, content, participantId }), - }); + async send(sessionId: string, content: string, participantId?: string, signal?: AbortSignal) { + return chatRequest( + '/api/chat/send', + { + method: 'POST', + body: JSON.stringify({ sessionId, content, participantId }), + }, + signal + ); }, - async getHistory(sessionId: string, options?: { limit?: number; before?: string }) { + async getHistory( + sessionId: string, + options?: { limit?: number; before?: string }, + signal?: AbortSignal + ) { const params = new URLSearchParams({ sessionId }); if (options?.limit) params.set('limit', options.limit.toString()); if (options?.before) params.set('before', options.before); - return apiRequest<{ messages: ChatMessage[]; hasMore: boolean }>( - `/api/chat/history?${params.toString()}` + return chatRequest<{ messages: ChatMessage[]; hasMore: boolean }>( + `/api/chat/history?${params.toString()}`, + {}, + signal ); }, }; From 60e7fa107618bd3e9c7487c35baff466731f10f5 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Mon, 14 Sep 2026 15:13:43 +0700 Subject: [PATCH 2/3] Update Next.js to address critical security advisories --- apps/web/next-env.d.ts | 1 + apps/web/package.json | 2 +- pnpm-lock.yaml | 447 +++++++++++++++++++++++++++++++++++------ 3 files changed, 384 insertions(+), 66 deletions(-) diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7..ce4e94a6 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 21f93a41..646542df 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,7 +30,7 @@ "livekit-server-sdk": "^2.15.0", "lucide-react": "^0.468.0", "marked": "^18.0.5", - "next": "^16.2.9", + "next": "^16.3.3", "posthog-js": "^1.381.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef8ca4f5..836d5553 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -349,13 +349,13 @@ importers: version: 1.0.3 '@profullstack/stack': specifier: ^0.1.3 - version: 0.1.3(@supabase/ssr@0.5.2(@supabase/supabase-js@2.91.0))(next@16.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 0.1.3(@supabase/ssr@0.5.2(@supabase/supabase-js@2.91.0))(next@16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@profullstack/x402-gateway': specifier: ^0.1.0 version: 0.1.0 '@serwist/next': specifier: ^9.5.0 - version: 9.5.0(next@16.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + version: 9.5.0(next@16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@supabase/ssr': specifier: ^0.5.0 version: 0.5.2(@supabase/supabase-js@2.91.0) @@ -384,8 +384,8 @@ importers: specifier: ^18.0.5 version: 18.0.5 next: - specifier: ^16.2.9 - version: 16.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^16.3.3 + version: 16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) posthog-js: specifier: ^1.381.0 version: 1.381.0 @@ -1562,6 +1562,9 @@ packages: resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} engines: {node: '>=16.4'} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -2069,139 +2072,285 @@ packages: resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.34.5': resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -2470,56 +2619,56 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@next/env@16.2.9': - resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + '@next/env@16.3.3': + resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} '@next/eslint-plugin-next@15.5.9': resolution: {integrity: sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==} - '@next/swc-darwin-arm64@16.2.9': - resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} + '@next/swc-darwin-arm64@16.3.3': + resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.9': - resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} + '@next/swc-darwin-x64@16.3.3': + resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.9': - resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} + '@next/swc-linux-arm64-gnu@16.3.3': + resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.9': - resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} + '@next/swc-linux-arm64-musl@16.3.3': + resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.9': - resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} + '@next/swc-linux-x64-gnu@16.3.3': + resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.9': - resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} + '@next/swc-linux-x64-musl@16.3.3': + resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.2.9': - resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} + '@next/swc-win32-arm64-msvc@16.3.3': + resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.9': - resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} + '@next/swc-win32-x64-msvc@16.3.3': + resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3038,8 +3187,8 @@ packages: resolution: {integrity: sha512-Rjb0QqkKrmXMVwUOdEqysPBZ0ZDZakeptTkUa6k2d8r3strBdbWVDqjOdkCjAmvvZMtXecBeyTyMEXD1Zzjfvg==} engines: {node: '>=20.0.0'} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} @@ -3828,8 +3977,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.37: - resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} + baseline-browser-mapping@2.11.23: + resolution: {integrity: sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -4035,6 +4184,9 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -6497,6 +6649,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nativewind@4.1.23: resolution: {integrity: sha512-oLX3suGI6ojQqWxdQezOSM5GmJ4KvMnMtmaSMN9Ggb5j7ysFt4nHxb1xs8RDjZR7BWc+bsetNJU8IQdQMHqRpg==} engines: {node: '>=16'} @@ -6524,8 +6681,8 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} - next@16.2.9: - resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} + next@16.3.3: + resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -7005,14 +7162,14 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.4.49: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -7699,6 +7856,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -10261,6 +10427,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -10814,100 +10985,207 @@ snapshots: '@img/colour@1.0.0': {} + '@img/colour@1.1.0': + optional: true + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.8.1 optional: true + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.4': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.4': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.4': + optional: true + '@inquirer/external-editor@1.0.3(@types/node@22.19.7)': dependencies: chardet: 2.1.1 @@ -11278,34 +11556,34 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@next/env@16.2.9': {} + '@next/env@16.3.3': {} '@next/eslint-plugin-next@15.5.9': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.9': + '@next/swc-darwin-arm64@16.3.3': optional: true - '@next/swc-darwin-x64@16.2.9': + '@next/swc-darwin-x64@16.3.3': optional: true - '@next/swc-linux-arm64-gnu@16.2.9': + '@next/swc-linux-arm64-gnu@16.3.3': optional: true - '@next/swc-linux-arm64-musl@16.2.9': + '@next/swc-linux-arm64-musl@16.3.3': optional: true - '@next/swc-linux-x64-gnu@16.2.9': + '@next/swc-linux-x64-gnu@16.3.3': optional: true - '@next/swc-linux-x64-musl@16.2.9': + '@next/swc-linux-x64-musl@16.3.3': optional: true - '@next/swc-win32-arm64-msvc@16.2.9': + '@next/swc-win32-arm64-msvc@16.3.3': optional: true - '@next/swc-win32-x64-msvc@16.2.9': + '@next/swc-win32-x64-msvc@16.3.3': optional: true '@nodelib/fs.scandir@2.1.5': @@ -11428,13 +11706,13 @@ snapshots: optionalDependencies: react: 19.2.3 - '@profullstack/stack@0.1.3(@supabase/ssr@0.5.2(@supabase/supabase-js@2.91.0))(next@16.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@profullstack/stack@0.1.3(@supabase/ssr@0.5.2(@supabase/supabase-js@2.91.0))(next@16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': dependencies: '@profullstack/emailer': 1.0.3 '@profullstack/referrals': 0.1.0(react@19.2.3) optionalDependencies: '@supabase/ssr': 0.5.2(@supabase/supabase-js@2.91.0) - next: 16.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 '@profullstack/x402-gateway@0.1.0': {} @@ -11785,7 +12063,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@serwist/next@9.5.0(next@16.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3)': + '@serwist/next@9.5.0(next@16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3)': dependencies: '@serwist/build': 9.5.0(typescript@5.9.3) '@serwist/utils': 9.5.0 @@ -11794,7 +12072,7 @@ snapshots: browserslist: 4.28.1 glob: 10.5.0 kolorist: 1.8.0 - next: 16.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 semver: 7.7.3 serwist: 9.5.0(typescript@5.9.3) @@ -11878,7 +12156,7 @@ snapshots: - bufferutil - utf-8-validate - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -12904,7 +13182,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.37: {} + baseline-browser-mapping@2.11.23: {} baseline-browser-mapping@2.9.17: {} @@ -13191,6 +13469,8 @@ snapshots: caniuse-lite@1.0.30001766: {} + caniuse-lite@1.0.30001810: {} + caseless@0.12.0: optional: true @@ -16122,6 +16402,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.19: {} + nativewind@4.1.23(react-native-reanimated@3.16.7(@babel/core@7.28.6)(react-native@0.76.9(@babel/core@7.28.6)(@babel/preset-env@7.28.6(@babel/core@7.28.6))(@types/react@18.3.31)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.9(@babel/core@7.28.6)(@babel/preset-env@7.28.6(@babel/core@7.28.6))(@types/react@18.3.31)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.28.6)(@babel/preset-env@7.28.6(@babel/core@7.28.6))(@types/react@18.3.31)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19(tsx@4.21.0)): dependencies: comment-json: 4.5.1 @@ -16148,28 +16430,29 @@ snapshots: nested-error-stacks@2.0.1: {} - next@16.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.3.3(@types/node@22.19.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@next/env': 16.2.9 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.37 - caniuse-lite: 1.0.30001766 - postcss: 8.4.31 + '@next/env': 16.3.3 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.23 + caniuse-lite: 1.0.30001810 + postcss: 8.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.9 - '@next/swc-darwin-x64': 16.2.9 - '@next/swc-linux-arm64-gnu': 16.2.9 - '@next/swc-linux-arm64-musl': 16.2.9 - '@next/swc-linux-x64-gnu': 16.2.9 - '@next/swc-linux-x64-musl': 16.2.9 - '@next/swc-win32-arm64-msvc': 16.2.9 - '@next/swc-win32-x64-msvc': 16.2.9 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 16.3.3 + '@next/swc-darwin-x64': 16.3.3 + '@next/swc-linux-arm64-gnu': 16.3.3 + '@next/swc-linux-arm64-musl': 16.3.3 + '@next/swc-linux-x64-gnu': 16.3.3 + '@next/swc-linux-x64-musl': 16.3.3 + '@next/swc-win32-arm64-msvc': 16.3.3 + '@next/swc-win32-x64-msvc': 16.3.3 + sharp: 0.35.4(@types/node@22.19.7) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros nice-try@1.0.5: {} @@ -16625,15 +16908,15 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.31: + postcss@8.4.49: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.4.49: + postcss@8.5.23: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -17431,6 +17714,40 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + sharp@0.35.4(@types/node@22.19.7): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 22.19.7 + optional: true + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 From ca2a916829c685f8ae00ec26fec12e1aaa706cd6 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Mon, 14 Sep 2026 16:10:12 +0700 Subject: [PATCH 3/3] Exercise late chat bodies and abort rejections through the real hook --- .../src/hooks/useChat-late-response.test.ts | 79 +++++++++++++++++++ apps/mobile/src/lib/api/chat-delivery.test.ts | 64 +++++++++------ 2 files changed, 119 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/src/hooks/useChat-late-response.test.ts diff --git a/apps/mobile/src/hooks/useChat-late-response.test.ts b/apps/mobile/src/hooks/useChat-late-response.test.ts new file mode 100644 index 00000000..c15869f7 --- /dev/null +++ b/apps/mobile/src/hooks/useChat-late-response.test.ts @@ -0,0 +1,79 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChatMessage } from '@pairux/shared-types'; +import { useChat } from './useChat'; +import { CHAT_REQUEST_TIMEOUT_MS } from '../lib/api/chat'; + +vi.mock('../lib/auth-session', () => ({ + getValidAccessToken: vi.fn().mockResolvedValue('test-token'), +})); + +describe('late responses through the actual chat transport and hook', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it.each(['resolve', 'reject'] as const)( + 'does not commit or resend a timed-out message when its body later %ss', + async (completion) => { + const message = { + id: 'committed-message', + session_id: 'room', + content: 'once', + created_at: '2026-01-01T00:00:00.000Z', + user_id: 'user', + } as ChatMessage; + let history: ChatMessage[] = []; + let completeBody!: () => void; + const posts = vi.fn(); + vi.mocked(fetch).mockImplementation((_url, options) => { + if (options?.method === 'POST') { + posts(); + return Promise.resolve({ + ok: true, + json: () => + new Promise((resolve, reject) => { + completeBody = () => + completion === 'resolve' + ? resolve({ data: message }) + : reject(new DOMException('Aborted', 'AbortError')); + }), + } as Response); + } + return Promise.resolve({ + ok: true, + json: async () => ({ data: { messages: history, hasMore: false } }), + } as Response); + }); + const { result, unmount } = renderHook(() => useChat({ sessionId: 'room' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.historyReady).toBe(true); + let pending!: ReturnType; + act(() => { + pending = result.current.sendMessage('once'); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS); + }); + expect(await pending).toEqual({ status: 'unknown' }); + expect(result.current.sending).toBe(false); + await act(async () => { + completeBody(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.messages).toEqual([]); + expect(result.current.sending).toBe(false); + expect(posts).toHaveBeenCalledTimes(1); + // Only a later history read may reconcile an already committed server row. + history = [message]; + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + }); + expect(result.current.messages).toEqual([message]); + expect(posts).toHaveBeenCalledTimes(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + } + ); +}); diff --git a/apps/mobile/src/lib/api/chat-delivery.test.ts b/apps/mobile/src/lib/api/chat-delivery.test.ts index 4acc2b2c..5bb1873c 100644 --- a/apps/mobile/src/lib/api/chat-delivery.test.ts +++ b/apps/mobile/src/lib/api/chat-delivery.test.ts @@ -68,32 +68,45 @@ describe('bounded chat requests', () => { const controller = new AbortController(); const pending = chatApi.send('room', 'cancelled draft', undefined, controller.signal); controller.abort(); - expect(await pending).toMatchObject({ failureKind: 'unknown' }); + await vi.advanceTimersByTimeAsync(0); + expect(await pending).toEqual({ error: 'Chat request interrupted', failureKind: 'unknown' }); finishAuth('late-token'); await vi.advanceTimersByTimeAsync(1); expect(fetch).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); - it('keeps a timed-out send unknown when its body subsequently resolves', async () => { - let finishBody!: (value: unknown) => void; - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: () => - new Promise((resolve) => { - finishBody = resolve; - }), - } as Response); - const pending = chatApi.send('room', 'once'); - await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS); - const outcome = await pending; - expect(outcome).toMatchObject({ failureKind: 'unknown' }); - finishBody({ data: { id: 'committed-message', session_id: 'room' } }); - await vi.advanceTimersByTimeAsync(1); - expect(await pending).toBe(outcome); - expect(fetch).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }); + it.each(['fetch', 'body'] as const)( + 'handles the %s rejection produced by a deadline abort', + async (stage) => { + const rejected = vi.fn(); + vi.mocked(fetch).mockImplementation((_url, options) => { + const waitForAbort = () => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + rejected(); + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true } + ); + }); + if (stage === 'fetch') return waitForAbort(); + const response = new Response(null, { status: 200 }); + vi.spyOn(response, 'json').mockImplementation(waitForAbort); + return Promise.resolve(response); + }); + const pending = chatApi.send('room', 'once'); + await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS); + expect(await pending).toEqual({ error: 'Chat request interrupted', failureKind: 'unknown' }); + await vi.advanceTimersByTimeAsync(1); + expect(rejected).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetch).mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + } + ); it('removes cancellation listeners and deadlines after a successful send', async () => { const response = { data: { id: 'confirmed-message', session_id: 'room' } }; @@ -116,11 +129,14 @@ describe('bounded chat requests', () => { vi.mocked(fetch).mockImplementation(() => new Promise(() => {})); const pending = chatApi.getHistory('room', { limit: 25 }); await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS); - expect(await pending).toMatchObject({ failureKind: 'unknown' }); + expect(await pending).toEqual({ error: 'Chat request interrupted', failureKind: 'unknown' }); expect(fetch).toHaveBeenCalledTimes(1); - expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain( - '/api/chat/history?sessionId=room&limit=25' - ); + const request = vi.mocked(fetch).mock.calls[0]?.[0]; + if (!request) throw new Error('Expected a history request'); + const url = new URL(request instanceof Request ? request.url : request); + expect(url.pathname).toBe('/api/chat/history'); + expect(url.searchParams.get('sessionId')).toBe('room'); + expect(url.searchParams.get('limit')).toBe('25'); expect(vi.mocked(fetch).mock.calls[0]?.[1]?.signal?.aborted).toBe(true); expect(vi.getTimerCount()).toBe(0); });