Compare commits
3
Commits
f528e5e440
...
6d63c34b2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d63c34b2c | ||
|
|
4c671fbf3a | ||
|
|
9d84f9153a |
@@ -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' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user