Compare commits

...
3 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 6d63c34b2c fix(upload): retry on dropped connections — the SDK reports XHR network failures as AbortError, which we treated as a user cancel (#172)
CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m6s
matrix-js-sdk rejects an upload whose XHR ends with status 0 (offline,
connection reset, DNS) with DOMException('AbortError') to mimic fetch,
the same name mx.cancelUpload() produces. isRetryableUploadError bailed
on any AbortError, so the one failure class the retry loop was built
for was never retried. Decide by our own cancel AbortSignal instead.

Verified with Playwright routing the upload endpoint: 502 → network drop
→ ok now completes in 3 attempts (1 s, 2 s back-off) and the image sends;
413 still fails fast after 1 attempt; persistent 503 gives up after 4.
Unit tests in utils/uploadRetry.test.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 17:58:15 -04:00
jaredandClaude Opus 5 4c671fbf3a fix(mobile): member list no longer opens on top of every room on phones (#175)
Room.tsx showed the MembersDrawer on phone widths whenever the persisted
desktop preference isPeopleDrawer was true — which is the default — so a
new mobile session opened every room behind a full-screen member list.
Mobile now uses a transient mobileMembersPanelAtom (header ⋮ → Members
toggles it, the drawer's X closes it, leaving the room resets it); the
desktop drawer keeps using the setting. Verified with Playwright at 400px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 17:45:43 -04:00
jaredandClaude Opus 5 9d84f9153a fix(invite): the header Invite dialog's QR was still fetched from api.qrserver.com — render it locally (#192)
a899d7d3 moved the Room Settings share QR to qrcode.react but the
InviteUserPrompt (header ⋮ → Invite, Room Intro, space menu) kept the
third-party <img>, which leaked the room link and — since the prod CSP
img-src no longer allows that host — rendered as a broken image. Same
QRCodeSVG + white quiet zone as RoomShareInvite. Verified with
Playwright: SVG present, no remote <img>, zero external requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 00:47:37 -04:00
7 changed files with 120 additions and 22 deletions
@@ -33,6 +33,7 @@ import {
import { Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import { QRCodeSVG } from 'qrcode.react';
import { stopPropagation } from '../../utils/keyboard';
import { useDirectUsers } from '../../hooks/useDirectUsers';
import {
@@ -237,13 +238,25 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(inviteUrl)}`}
alt="QR code for room invite link"
width={180}
height={180}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
{/* Generated locally (qrcode.react) like RoomShareInvite — the
old api.qrserver.com <img> leaked the room link to a third
party and is blocked by the prod CSP img-src anyway
(Gitea #192). White quiet-zone so it scans on any theme. */}
<Box
style={{
padding: config.space.S200,
background: '#ffffff',
borderRadius: config.radii.R300,
lineHeight: 0,
}}
>
<QRCodeSVG
value={inviteUrl}
size={164}
level="M"
title="QR code for room invite link"
/>
</Box>
<Text
size="T200"
style={{ opacity: 0.6, wordBreak: 'break-all', textAlign: 'center' }}
+8 -1
View File
@@ -31,6 +31,8 @@ import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { Membership } from '../../../types/matrix/room';
@@ -79,6 +81,8 @@ type MemberDrawerHeaderProps = {
};
function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const setMobileMembers = useSetAtom(mobileMembersPanelAtom);
const screenSize = useScreenSizeContext();
return (
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
@@ -104,7 +108,10 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
ref={triggerRef}
variant="Background"
aria-label="Close member list"
onClick={() => setPeopleDrawer(false)}
onClick={() => {
if (screenSize === ScreenSize.Mobile) setMobileMembers(false);
else setPeopleDrawer(false);
}}
>
<Icon src={Icons.Cross} />
</IconButton>
+11 -3
View File
@@ -2,13 +2,14 @@ import React, { useCallback, useEffect, useRef } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -46,6 +47,7 @@ export function Room() {
const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const [mobileMembersOpen, setMobileMembersOpen] = useAtom(mobileMembersPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const threadsListOpen = useAtomValue(threadsListAtom);
const setThreadsListOpen = useSetAtom(threadsListAtom);
@@ -146,10 +148,16 @@ export function Room() {
threadsListOpen &&
!activeThreadId &&
(isDesktop || (!galleryOpen && !widgetsOpen));
// Desktop: the persisted drawer preference. Mobile: a transient panel the
// user opened from the header menu (never the desktop default).
const showMembers =
!callView &&
isDrawer &&
(isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen));
(isDesktop
? isDrawer
: mobileMembersOpen && !activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen);
// Leaving a room on a phone closes its member panel.
useEffect(() => () => setMobileMembersOpen(false), [room.roomId, setMobileMembersOpen]);
return (
<PowerLevelsContextProvider value={powerLevels}>
+5 -4
View File
@@ -75,6 +75,7 @@ import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -105,7 +106,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
const [invitePrompt, setInvitePrompt] = useState(false);
const [reportRoomOpen, setReportRoomOpen] = useState(false);
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [mobileMembers, setMobileMembers] = useAtom(mobileMembersPanelAtom);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
@@ -196,13 +197,13 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setPeopleDrawer(!peopleDrawer);
setMobileMembers(!mobileMembers);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.User} filled={peopleDrawer} />}
after={<Icon size="100" src={Icons.User} filled={mobileMembers} />}
radii="300"
aria-pressed={peopleDrawer}
aria-pressed={mobileMembers}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Members
+8
View File
@@ -0,0 +1,8 @@
import { atom } from 'jotai';
// Whether the member list is open on a phone-width layout, where it is a
// full-screen panel rather than a side drawer. Deliberately NOT the persisted
// `isPeopleDrawer` setting: that one remembers the desktop drawer (default
// on), and reusing it on mobile made every room open on top of the member
// list until the user found the X (Gitea #175).
export const mobileMembersPanelAtom = atom<boolean>(false);
+9 -7
View File
@@ -171,12 +171,14 @@ const matrixErrorFromUnknown = (e: unknown): MatrixError => {
// HTTP statuses that should not be retried — client errors are deterministic
// (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry.
const isRetryableUploadError = (e: unknown): boolean => {
// A user-cancelled / aborted upload must never be retried. matrix-js-sdk's
// mx.cancelUpload() rejects the upload with a DOMException named "AbortError";
// without this guard the retry loop would resurrect an upload the user just
// cancelled.
if ((e as { name?: unknown } | null | undefined)?.name === 'AbortError') return false;
const isRetryableUploadError = (e: unknown, cancelled: boolean): boolean => {
// A user-cancelled upload must never be retried. BUT matrix-js-sdk rejects
// with a DOMException named "AbortError" for BOTH mx.cancelUpload() and any
// XHR that ends with status 0 — a dropped connection, going offline, DNS —
// ("mimic fetch API", http-api/index.ts). Those are exactly the transient
// failures this retry loop exists for, so decide by OUR cancel signal, not
// by the error's name (Gitea #172).
if ((e as { name?: unknown } | null | undefined)?.name === 'AbortError') return !cancelled;
if (e instanceof MatrixError) {
const status = e.httpStatus;
// No status => network/transport failure (transient): retry.
@@ -249,7 +251,7 @@ export const uploadContent = async (
} catch (e: unknown) {
lastError = matrixErrorFromUnknown(e);
if (retryCount === UPLOAD_MAX_RETRY_COUNT || !isRetryableUploadError(e)) {
if (retryCount === UPLOAD_MAX_RETRY_COUNT || !isRetryableUploadError(e, !!signal?.aborted)) {
onError(lastError);
return;
}
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
import { uploadContent } from './matrix';
// Drives `uploadContent` with a fake client whose uploads fail per `plan`
// (one entry per attempt: an Error to reject with, or 'ok'). Back-off sleeps
// are real timers, so failures are kept to 1-2 retries per test.
const run = async (plan: Array<Error | 'ok'>, cancelAfterAttempt?: number) => {
let attempt = 0;
const controller = new AbortController();
const mx = {
uploadContent: () => {
attempt += 1;
const step = plan[attempt - 1];
if (cancelAfterAttempt === attempt) controller.abort();
return step === 'ok' ? Promise.resolve({ content_uri: 'mxc://x/y' }) : Promise.reject(step);
},
} as unknown as MatrixClient;
let outcome: { mxc?: string; error?: MatrixError } = {};
await uploadContent(mx, new Blob(['x']) as unknown as File, {
onProgress: () => undefined,
onSuccess: (mxc) => {
outcome = { mxc };
},
onError: (error) => {
outcome = { error };
},
signal: controller.signal,
});
return { attempts: attempt, ...outcome };
};
const abortError = () => new DOMException('request failed', 'AbortError');
test('a dropped connection (SDK reports it as AbortError) is retried', async () => {
const r = await run([abortError(), 'ok']);
assert.equal(r.attempts, 2);
assert.equal(r.mxc, 'mxc://x/y');
});
test('a user cancel (our signal aborted) is NOT retried even though it is also an AbortError', async () => {
const r = await run([abortError(), 'ok'], 1);
assert.equal(r.attempts, 1);
assert.equal(r.mxc, undefined);
assert.ok(r.error);
});
test('5xx is retried, 4xx fails fast', async () => {
const server = new MatrixError({ errcode: 'M_UNKNOWN', error: 'boom' }, 502);
const r = await run([server, 'ok']);
assert.equal(r.attempts, 2);
assert.equal(r.mxc, 'mxc://x/y');
const tooLarge = new MatrixError({ errcode: 'M_TOO_LARGE', error: 'big' }, 413);
const s = await run([tooLarge, 'ok']);
assert.equal(s.attempts, 1);
assert.equal(s.error?.httpStatus, 413);
});