feat: offline outbox — unsent messages survive reload and retry (#112)
CI / Build & Quality Checks (pull_request) Successful in 6m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 10m28s

Until now a send that failed (offline, homeserver down, a blip) went
straight to "Failed to send": nothing retried it, and a reload dropped it
without trace (chronological pending ordering keeps local echoes in memory
only).

- Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message
  sends (text, stickers, reactions, polls; not call signalling or
  redactions) are mirrored to localStorage from their first local echo until
  the server confirms them or the user cancels.
- After a reload they come back as local echoes via room.addPendingEvent,
  same shape as the SDK's own. Recent ones (< 1 h) are sent again with the
  same txnId; older ones come back as "Failed to send" for the user to
  retry or cancel. Ones the server already has (transaction id seen in
  /sync) are dropped, so no duplicates.
- Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when
  the connection returns (sync recovers or the browser goes back online),
  and after a blip while online (5 s, backing off, max 10 per message).
  Oldest first, in order per room. 4xx / consent / encryption failures are
  left to the user.
- UI: a network failure while offline shows a clock, "Queued. Will send
  when you're back online" (thread view too), not the red ✕. The ✕ is now
  a button: click to retry.
- Logout wipes the outbox with the other plaintext caches (the content is
  decrypted, like drafts).

Tested end to end against a local Synapse (Chromium): offline → queued →
sent once on reconnect; homeserver unreachable → queued → sent once; failed
send → reload → sent once and shown once; server accepted but response lost
→ reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent;
cancel → gone after reload; encrypted room → restored message goes out as
m.room.encrypted with no plaintext and decrypts; one-off failure retried by
itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright
20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-28 21:02:45 -04:00
co-authored by Claude Opus 5.5
parent c0c93213c1
commit 02d86caeb0
9 changed files with 676 additions and 30 deletions
+64 -29
View File
@@ -26,6 +26,7 @@ import {
config,
} from 'folds';
import React, {
CSSProperties,
FormEventHandler,
MouseEventHandler,
ReactNode,
@@ -38,7 +39,7 @@ import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { useAtom } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
AvatarBase,
@@ -98,27 +99,39 @@ import { useBookmarks } from '../../../hooks/useBookmarks';
import { PresenceRingAvatar } from '../../../components/presence';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
import { sendOfflineAtom } from '../../../state/sendOffline';
import { isRetryableSendError } from '../../../utils/outbox';
// Delivery status indicator for own messages
function DeliveryStatus({
status,
mEvent,
room,
lotusTerminal,
}: {
status: string | null;
mEvent: MatrixEvent;
room: Room;
lotusTerminal: boolean;
}) {
const mx = useMatrixClient();
const offline = useAtomValue(sendOfflineAtom);
const { status } = mEvent;
if (status === null) return null; // confirmed by server — read receipts take over
let iconSrc: IconSrc;
let label: string;
let colorStyle: string;
const isSending = status === EventStatus.SENDING || status === EventStatus.ENCRYPTING;
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
// [Gitea #112] A network failure while offline is queued, not failed: the
// outbox sends it again when the connection is back.
const queued = status === EventStatus.NOT_SENT && offline && isRetryableSendError(mEvent.error);
const failed = !queued && (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED);
if (failed) {
iconSrc = Icons.Cross;
label = 'Failed to send';
label = status === EventStatus.NOT_SENT ? 'Failed to send. Click to retry' : 'Failed to send';
colorStyle = lotusTerminal ? 'var(--lt-accent-red)' : color.Critical.Main;
} else if (status === EventStatus.QUEUED || isSending) {
iconSrc = Icons.Send;
label = isSending ? 'Sending...' : 'Queued';
} else if (queued || status === EventStatus.QUEUED || isSending) {
iconSrc = queued ? Icons.RecentClock : Icons.Send;
if (queued) label = "Queued. Will send when you're back online";
else label = isSending ? 'Sending...' : 'Queued';
colorStyle = lotusTerminal
? 'color-mix(in srgb, var(--lt-accent-cyan) 60%, transparent)'
: color.Secondary.Main;
@@ -129,27 +142,49 @@ function DeliveryStatus({
? 'color-mix(in srgb, var(--lt-accent-cyan) 70%, transparent)'
: color.Secondary.Main;
}
const retryable = failed && status === EventStatus.NOT_SENT;
const handleRetry: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.stopPropagation();
if (mEvent.status === EventStatus.NOT_SENT) mx.resendEvent(mEvent, room).catch(() => undefined);
};
const style: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
marginTop: '2px',
lineHeight: 1,
color: colorStyle,
opacity: 0.85,
userSelect: 'none',
...(lotusTerminal && failed ? { textShadow: 'var(--lt-glow-red)' } : {}),
};
const icon = (
<span className={isSending ? SendingSpinClass : undefined}>
<Icon size="100" src={iconSrc} />
</span>
);
if (retryable) {
return (
<button
type="button"
onClick={handleRetry}
aria-label={label}
title={label}
style={{
...style,
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
font: 'inherit',
}}
>
{icon}
</button>
);
}
return (
<Box
as="span"
aria-label={label}
title={label}
style={{
display: 'inline-flex',
alignItems: 'center',
marginTop: '2px',
lineHeight: 1,
color: colorStyle,
opacity: 0.85,
userSelect: 'none',
...(lotusTerminal && status === EventStatus.NOT_SENT
? { textShadow: 'var(--lt-glow-red)' }
: {}),
}}
>
<span className={isSending ? SendingSpinClass : undefined}>
<Icon size="100" src={iconSrc} />
</span>
<Box as="span" aria-label={label} title={label} style={style}>
{icon}
</Box>
);
}
@@ -1102,7 +1137,7 @@ export const Message = React.memo(
/>
)}
{isMine && !mEvent.isState() && readReceiptUsers.length === 0 && (
<DeliveryStatus status={mEvent.status} lotusTerminal={!!lotusTerminal} />
<DeliveryStatus mEvent={mEvent} room={room} lotusTerminal={!!lotusTerminal} />
)}
</Box>
);
@@ -33,6 +33,8 @@ import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, conf
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { isKeyHotkey } from 'is-hotkey';
import { isRetryableSendError } from '../../../utils/outbox';
import { sendOfflineAtom } from '../../../state/sendOffline';
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
@@ -253,6 +255,7 @@ export type ThreadTimelineProps = {
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const mx = useMatrixClient();
const sendOffline = useAtomValue(sendOfflineAtom);
const alive = useAlive();
const useAuthentication = useMediaAuthentication();
@@ -992,8 +995,12 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const showEmptyReplies = ready && thread.length === 0;
const renderPendingEvent = (mEvent: MatrixEvent) => {
// [Gitea #112] Network failures while offline are queued, not failed.
const queued =
mEvent.status === EventStatus.NOT_SENT && sendOffline && isRetryableSendError(mEvent.error);
const failed =
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
!queued &&
(mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED);
return (
<div
key={mEvent.getId() ?? mEvent.getTxnId()}
@@ -1007,6 +1014,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
</Text>
</Box>
)}
{queued && (
<Box style={{ padding: `0 ${config.space.S400}` }}>
<Text size="T200" priority="300">
Queued. Will send when you&apos;re back online
</Text>
</Box>
)}
</div>
);
};