feat(web): pending-message orchestrator (TTL discard, deferred send, online retry)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user