diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index dd651824..a5c40a89 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -78,6 +78,15 @@ export class RateLimitError extends Error { } } +export class HttpError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = 'HttpError'; + this.status = status; + } +} + export class BackspaceApiClient { readonly auth: { register: (data: RegisterRequest) => Promise; @@ -316,7 +325,7 @@ export class BackspaceApiClient { throw new RateLimitError(retryAfter); } const error = await response.json().catch(() => ({ error: 'Request failed' })); - throw new Error((error as { error: string }).error || `HTTP ${response.status}`); + throw new HttpError(response.status, (error as { error: string }).error || `HTTP ${response.status}`); } return response.json() as Promise; @@ -363,7 +372,7 @@ export class BackspaceApiClient { throw new RateLimitError(retryAfter); } const error = await response.json().catch(() => ({ error: 'Upload failed' })); - throw new Error((error as { error: string }).error || `HTTP ${response.status}`); + throw new HttpError(response.status, (error as { error: string }).error || `HTTP ${response.status}`); } return response.json() as Promise; diff --git a/packages/web/src/hooks/useNetworkStatus.ts b/packages/web/src/hooks/useNetworkStatus.ts new file mode 100644 index 00000000..1ebdf4e6 --- /dev/null +++ b/packages/web/src/hooks/useNetworkStatus.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +export function useNetworkStatus(): boolean { + const [online, setOnline] = useState(navigator.onLine); + useEffect(() => { + const on = () => setOnline(true); + const off = () => setOnline(false); + window.addEventListener('online', on); + window.addEventListener('offline', off); + return () => { + window.removeEventListener('online', on); + window.removeEventListener('offline', off); + }; + }, []); + return online; +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 9cf56786..f8fdedf4 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; +import { startPendingMessageOrchestrator } from './stores/pendingMessageRehydrate'; import './styles/globals.css'; class ErrorBoundary extends React.Component< @@ -105,6 +106,8 @@ class ErrorBoundary extends React.Component< const root = document.getElementById('root'); if (!root) throw new Error('Root element not found'); +startPendingMessageOrchestrator(); + ReactDOM.createRoot(root).render( diff --git a/packages/web/src/stores/pendingMessageRehydrate.ts b/packages/web/src/stores/pendingMessageRehydrate.ts new file mode 100644 index 00000000..5e319ca9 --- /dev/null +++ b/packages/web/src/stores/pendingMessageRehydrate.ts @@ -0,0 +1,126 @@ +import { usePendingMessageStore, type PendingBubble } from './pendingMessageStore'; +import { useTransferStore } from './transferStore'; +import { useUIStore } from './uiStore'; +import { getApiForOrigin, getChannelOrigin, isDmChannel } from './spaceStore'; +import { HttpError, RateLimitError } from '../api/client'; + +let started = false; + +/** Call once at app start. Idempotent against StrictMode/HMR remounts. */ +export function startPendingMessageOrchestrator(): void { + if (started) return; + started = true; + + // 1. TTL discard with toast + const dropped = usePendingMessageStore.getState().discardExpired(Date.now()); + for (const b of dropped) { + useUIStore.getState().addToast( + `Couldn't send "${b.content || '(attachment-only)'}" — upload expired.`, + 'warning', + ); + } + + // 2. All-transfers-already-complete branch (handles bubbles whose uploads + // finished while the tab was closed) + const ready = usePendingMessageStore.getState().listReadyForDeferredSend(); + for (const b of ready) { + if (!sentClientIds.has(b.clientId)) { + sentClientIds.add(b.clientId); + void deferredSend(b); + } + } + + // 3. Subscribe: any time relevant transfer state changes, re-check ready + // bubbles. Dedup by a (id|state|attachmentId) signature so we don't + // re-run on every progress tick. `sentClientIds` further dedups + // per-bubble dispatch. + let lastStateSig = ''; + useTransferStore.subscribe((s) => { + const sig = Array.from(s.transfers.values()) + .map((t) => `${t.id}|${t.state}|${t.attachmentId ?? ''}`) + .sort() + .join(','); + if (sig === lastStateSig) return; + lastStateSig = sig; + + const fresh = usePendingMessageStore.getState().listReadyForDeferredSend(); + for (const b of fresh) { + if (!sentClientIds.has(b.clientId)) { + sentClientIds.add(b.clientId); + void deferredSend(b); + } + } + }); + + // 4. Auto-retry on `online`: bump+resend bubbles that failed once with no + // prior retries (network-induced failure most likely). Uses the + // failed-retryable query — listReadyForDeferredSend gates state==='sending' + // and would never return failed bubbles. + window.addEventListener('online', () => { + const list = usePendingMessageStore.getState().listFailedRetryable(); + for (const b of list) { + usePendingMessageStore.getState().bumpRetry(b.clientId); + usePendingMessageStore.getState().markSending(b.clientId); + // Add to dispatch set: the markSending transition could re-fire the + // subscribe handler indirectly via downstream transferStore changes, + // and we want only one in-flight send for this bubble. + sentClientIds.add(b.clientId); + void deferredSend(b, 1); // online auto-retry counts as the one allowed retry + } + }); +} + +const sentClientIds = new Set(); + +async function deferredSend(b: PendingBubble, attempt = 0): Promise { + const transfers = useTransferStore.getState().transfers; + const attachmentIds = b.transferIds + .map((tid) => transfers.get(tid)?.attachmentId) + .filter((id): id is string => Boolean(id)); + + let client; + let isDm: boolean; + try { + const origin = getChannelOrigin(b.channelId); + client = getApiForOrigin(origin); + isDm = isDmChannel(b.channelId); + } catch (err) { + console.warn('[pendingMessageRehydrate] origin resolution failed for', b.clientId, err); + usePendingMessageStore.getState().markFailed(b.clientId); + return; + } + + try { + if (isDm) { + await client.dm.sendMessage(b.channelId, { + content: b.content, + attachments: attachmentIds, + replyToId: b.replyToId ?? undefined, + }); + } else { + await client.channels.sendMessage(b.channelId, { + content: b.content, + attachments: attachmentIds, + replyToId: b.replyToId ?? undefined, + }); + } + usePendingMessageStore.getState().removeByClientId(b.channelId, b.clientId); + } catch (err) { + const status = err instanceof HttpError ? err.status : 0; + const isRateLimited = err instanceof RateLimitError; + + // 4xx (client error) and rate limits are permanent — mark failed, + // user retries manually. + if ((status >= 400 && status < 500) || isRateLimited) { + usePendingMessageStore.getState().markFailed(b.clientId); + return; + } + // Network error / 5xx: one immediate retry while online, then mark failed. + if (attempt === 0 && navigator.onLine) { + usePendingMessageStore.getState().bumpRetry(b.clientId); + void deferredSend(b, 1); + return; + } + usePendingMessageStore.getState().markFailed(b.clientId); + } +} diff --git a/packages/web/src/stores/pendingMessageStore.test.ts b/packages/web/src/stores/pendingMessageStore.test.ts index 9f146400..669d8968 100644 --- a/packages/web/src/stores/pendingMessageStore.test.ts +++ b/packages/web/src/stores/pendingMessageStore.test.ts @@ -107,4 +107,36 @@ describe('pendingMessageStore', () => { expect(dropped).toEqual([]); expect(after).toBe(before); // same Map reference }); + + it('listFailedRetryable returns failed bubbles with retryCount=0 and all transfers complete', () => { + useTransferStore.setState({ + transfers: new Map([ + ['t-1', makeTransfer({ id: 't-1', state: 'completed', attachmentId: 'att-1' })], + ]), + }); + usePendingMessageStore.getState().append(bubble({ clientId: 'fail-ready', transferIds: ['t-1'] })); + usePendingMessageStore.getState().markFailed('fail-ready'); + expect(usePendingMessageStore.getState().listFailedRetryable().map((b) => b.clientId)).toEqual(['fail-ready']); + }); + + it('listFailedRetryable excludes bubbles already retried', () => { + useTransferStore.setState({ + transfers: new Map([['t-1', makeTransfer({ id: 't-1', state: 'completed', attachmentId: 'att-1' })]]), + }); + usePendingMessageStore.getState().append(bubble({ clientId: 'retried', transferIds: ['t-1'], retryCount: 1 })); + usePendingMessageStore.getState().markFailed('retried'); + expect(usePendingMessageStore.getState().listFailedRetryable()).toEqual([]); + }); + + it('listFailedRetryable excludes bubbles whose transfers are not all complete', () => { + useTransferStore.setState({ + transfers: new Map([ + ['t-1', makeTransfer({ id: 't-1', state: 'completed', attachmentId: 'att-1' })], + ['t-2', makeTransfer({ id: 't-2', state: 'paused' })], // no attachmentId + ]), + }); + usePendingMessageStore.getState().append(bubble({ clientId: 'partial', transferIds: ['t-1', 't-2'] })); + usePendingMessageStore.getState().markFailed('partial'); + expect(usePendingMessageStore.getState().listFailedRetryable()).toEqual([]); + }); }); diff --git a/packages/web/src/stores/pendingMessageStore.ts b/packages/web/src/stores/pendingMessageStore.ts index 01858758..b55ef529 100644 --- a/packages/web/src/stores/pendingMessageStore.ts +++ b/packages/web/src/stores/pendingMessageStore.ts @@ -34,6 +34,7 @@ interface PendingMessageStoreActions { ) => PendingBubble | null; discardExpired: (now: number) => PendingBubble[]; listReadyForDeferredSend: () => PendingBubble[]; + listFailedRetryable: () => PendingBubble[]; } type PendingMessageStore = PendingMessageStoreState & PendingMessageStoreActions; @@ -200,6 +201,23 @@ export const usePendingMessageStore = create()( } return ready; }, + + listFailedRetryable: () => { + const transfers = useTransferStore.getState().transfers; + const out: PendingBubble[] = []; + for (const list of get().bubbles.values()) { + for (const b of list) { + if (b.state !== 'failed') continue; + if (b.retryCount > 0) continue; + const allDone = b.transferIds.every((tid) => { + const t = transfers.get(tid); + return t?.state === 'completed' && !!t.attachmentId; + }); + if (allDone) out.push(b); + } + } + return out; + }, }), { name: 'pendingMessageStore@v1',