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-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/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..5bb1873c
--- /dev/null
+++ b/apps/mobile/src/lib/api/chat-delivery.test.ts
@@ -0,0 +1,170 @@
+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('does not POST after cancellation while authentication is pending', async () => {
+ let finishAuth!: (value: string) => void;
+ vi.mocked(getValidAccessToken).mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishAuth = resolve;
+ })
+ );
+ const controller = new AbortController();
+ const pending = chatApi.send('room', 'cancelled draft', undefined, controller.signal);
+ controller.abort();
+ 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.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' } };
+ vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => response } as Response);
+ const controller = new AbortController();
+ const add = vi.spyOn(controller.signal, 'addEventListener');
+ const remove = vi.spyOn(controller.signal, 'removeEventListener');
+ expect(await chatApi.send('room', 'once', undefined, controller.signal)).toEqual(response);
+ const listener = add.mock.calls.find(([event]) => event === 'abort')?.[1];
+ expect(listener).toBeTypeOf('function');
+ expect(remove).toHaveBeenCalledWith('abort', listener);
+ controller.abort();
+ await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(fetch).mock.calls[0]?.[1]?.signal?.aborted).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('bounds stalled history requests without creating additional requests', async () => {
+ vi.mocked(fetch).mockImplementation(() => new Promise(() => {}));
+ const pending = chatApi.getHistory('room', { limit: 25 });
+ await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS);
+ expect(await pending).toEqual({ error: 'Chat request interrupted', failureKind: 'unknown' });
+ expect(fetch).toHaveBeenCalledTimes(1);
+ 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);
+ });
+
+ 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
);
},
};
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