feat(web): pending-message orchestrator (TTL discard, deferred send, online retry)

This commit is contained in:
Jannis Braun
2026-05-02 16:00:30 +02:00
parent b01ad4edb8
commit 5128a625b8
6 changed files with 206 additions and 2 deletions
+11 -2
View File
@@ -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<AuthResponse>;
@@ -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<T>;
@@ -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<Attachment>;
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react';
export function useNetworkStatus(): boolean {
const [online, setOnline] = useState<boolean>(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;
}
+3
View File
@@ -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(
<React.StrictMode>
<ErrorBoundary>
@@ -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<string>();
async function deferredSend(b: PendingBubble, attempt = 0): Promise<void> {
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);
}
}
@@ -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([]);
});
});
@@ -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<PendingMessageStore>()(
}
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',