Compare commits
3
Commits
a475531b2b
...
464951edf4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
464951edf4 | ||
|
|
6df160a7bf | ||
|
|
60076a48d0 |
@@ -6,6 +6,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { TUploadContent } from '../../utils/matrix';
|
||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { describeUploadError } from '../../utils/uploadError';
|
||||
|
||||
type CompactUploadCardRendererProps = {
|
||||
isEncrypted?: boolean;
|
||||
@@ -91,7 +92,7 @@ export function CompactUploadCardRenderer({
|
||||
)}
|
||||
{upload.status === UploadStatus.Error && (
|
||||
<UploadCardError>
|
||||
<Text size="T200">{upload.error.message}</Text>
|
||||
<Text size="T200">{describeUploadError(upload.error, allowSize)}</Text>
|
||||
</UploadCardError>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { describeUploadError } from '../../utils/uploadError';
|
||||
import { compressImage, formatFileSize, isCompressible } from '../../utils/imageCompression';
|
||||
|
||||
type PreviewImageProps = {
|
||||
@@ -389,7 +390,7 @@ export function UploadCardRenderer({
|
||||
)}
|
||||
{upload.status === UploadStatus.Error && (
|
||||
<UploadCardError>
|
||||
<Text size="T200">{upload.error.message}</Text>
|
||||
<Text size="T200">{describeUploadError(upload.error, allowSize)}</Text>
|
||||
</UploadCardError>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||
|
||||
@@ -113,7 +113,14 @@ import {
|
||||
} from './msgContent';
|
||||
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
||||
import {
|
||||
Command,
|
||||
CommandError,
|
||||
SHRUG,
|
||||
TABLEFLIP,
|
||||
UNFLIP,
|
||||
useCommands,
|
||||
} from '../../hooks/useCommands';
|
||||
import { mobileOrTablet } from '../../utils/user-agent';
|
||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
||||
@@ -665,16 +672,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const commandContent = commands[commandName as Command];
|
||||
if (commandContent) {
|
||||
// Fire-and-forget by design (the editor resets immediately for UX), but
|
||||
// surface a rejection instead of failing silently. NOTE: /kick and /ban
|
||||
// route through rateLimitedActions (utils/matrix.ts), whose to() helper
|
||||
// swallows non-429 errors, so those two commands can still resolve even
|
||||
// when the underlying kick/ban failed — this catch only covers errors
|
||||
// that actually reject out of exe().
|
||||
// surface a rejection instead of failing silently. /kick and /ban
|
||||
// throw a CommandError whose message already names who and why
|
||||
// (#216); anything else gets the generic sentence.
|
||||
commandContent.exe(plainText).catch((err) => {
|
||||
console.error(`Failed to run /${commandName} command:`, err);
|
||||
setToast(
|
||||
createErrorToast(
|
||||
`The /${commandName} command failed. Please try again.`,
|
||||
err instanceof CommandError
|
||||
? err.message
|
||||
: `The /${commandName} command failed. Please try again.`,
|
||||
Icons.Warning,
|
||||
'Command failed',
|
||||
),
|
||||
|
||||
@@ -705,6 +705,34 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}));
|
||||
return;
|
||||
}
|
||||
// [Gitea #212] Your OWN new message always comes into view — that is
|
||||
// what pressing Send means — even if the at-bottom flag went stale
|
||||
// (in-place growth like a poll gaining a row briefly pushes the anchor
|
||||
// out of the viewport) or you had scrolled up to read.
|
||||
const ownNewMessage =
|
||||
mEvt.getSender() === mx.getUserId() &&
|
||||
!mEvt.isRelation() &&
|
||||
!mEvt.isRedaction() &&
|
||||
!mEvt.isState() &&
|
||||
mEvt.getType() !== MessageEvent.Reaction;
|
||||
if (ownNewMessage) {
|
||||
if (atLiveEndRef.current) {
|
||||
setAtBottom(true);
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
setTimeline((ct) => ({
|
||||
...ct,
|
||||
range: { start: ct.range.start + 1, end: ct.range.end + 1 },
|
||||
}));
|
||||
} else {
|
||||
// Paginated back into history: jump to the live end like the
|
||||
// "Jump to Latest" button does.
|
||||
setTimeline(getInitialTimeline(room));
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTimeline((ct) => ({ ...ct }));
|
||||
if (!unreadInfo) {
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isServerName,
|
||||
isUserId,
|
||||
rateLimitedActions,
|
||||
RateLimitedFailure,
|
||||
removeRoomIdFromMDirect,
|
||||
} from '../utils/matrix';
|
||||
import { useRoomNavigate } from './useRoomNavigate';
|
||||
@@ -138,6 +139,28 @@ export const parseTimestampFlag = (input: string): number | undefined => {
|
||||
|
||||
export type CommandExe = (payload: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* [Gitea #216] A command that partially or fully failed. `message` is written
|
||||
* for the user (the RoomInput toast shows it verbatim).
|
||||
*/
|
||||
export class CommandError extends Error {}
|
||||
|
||||
const throwCommandFailures = (
|
||||
verb: 'kick' | 'ban',
|
||||
failures: RateLimitedFailure<string>[],
|
||||
): void => {
|
||||
if (failures.length === 0) return;
|
||||
const who = failures.map((f) => f.item).join(', ');
|
||||
// MatrixError.data.error is the server's sentence ("You don't have
|
||||
// permission to kick"); fall back to the errcode, never the URL-bearing
|
||||
// toString().
|
||||
const reason =
|
||||
(failures[0].error.data as { error?: string } | undefined)?.error ??
|
||||
failures[0].error.errcode ??
|
||||
'unknown error';
|
||||
throw new CommandError(`Could not ${verb} ${who}: ${reason}`);
|
||||
};
|
||||
|
||||
export enum Command {
|
||||
Me = 'me',
|
||||
Notice = 'notice',
|
||||
@@ -292,11 +315,13 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: rateLimitedActions' to() helper (utils/matrix.ts) swallows every
|
||||
// non-429 error, so a failed kick (e.g. insufficient power level) resolves
|
||||
// silently here — the RoomInput exe() catch cannot surface it. Propagating
|
||||
// would require refactoring rateLimitedActions' shared error handling.
|
||||
await rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason));
|
||||
// [Gitea #216] rateLimitedActions collects per-user failures (it
|
||||
// used to swallow them); turn them into one CommandError so the
|
||||
// RoomInput toast can say who and why.
|
||||
const { failures } = await rateLimitedActions(users, (id) =>
|
||||
mx.kick(room.roomId, id, reason),
|
||||
);
|
||||
throwCommandFailures('kick', failures);
|
||||
},
|
||||
},
|
||||
[Command.Ban]: {
|
||||
@@ -318,9 +343,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||
});
|
||||
}
|
||||
|
||||
// See the /kick note: rateLimitedActions swallows non-429 errors, so a
|
||||
// failed ban resolves silently and can't be surfaced by the exe() catch.
|
||||
await rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason));
|
||||
const { failures } = await rateLimitedActions(users, (id) =>
|
||||
mx.ban(room.roomId, id, reason),
|
||||
);
|
||||
throwCommandFailures('ban', failures);
|
||||
},
|
||||
},
|
||||
[Command.UnBan]: {
|
||||
|
||||
+25
-6
@@ -221,12 +221,18 @@ export const uploadContent = async (
|
||||
|
||||
const abortError = () =>
|
||||
matrixErrorFromUnknown(new DOMException('Upload cancelled', 'AbortError'));
|
||||
// The card shows a plain sentence (describeUploadError); keep the raw error
|
||||
// (status, errcode, URL) in the console for debugging.
|
||||
const fail = (err: MatrixError) => {
|
||||
if (err.data?.error !== 'Upload cancelled') console.warn('[upload] failed:', err);
|
||||
onError(err);
|
||||
};
|
||||
|
||||
let lastError: MatrixError | undefined;
|
||||
|
||||
for (let retryCount = 0; retryCount <= UPLOAD_MAX_RETRY_COUNT; retryCount += 1) {
|
||||
if (signal?.aborted) {
|
||||
onError(abortError());
|
||||
fail(abortError());
|
||||
return;
|
||||
}
|
||||
const uploadPromise = mx.uploadContent(file, {
|
||||
@@ -246,13 +252,13 @@ export const uploadContent = async (
|
||||
return;
|
||||
}
|
||||
// Missing content_uri is not a transient failure — fail immediately.
|
||||
onError(matrixErrorFromUploadResponse(data));
|
||||
fail(matrixErrorFromUploadResponse(data));
|
||||
return;
|
||||
} catch (e: unknown) {
|
||||
lastError = matrixErrorFromUnknown(e);
|
||||
|
||||
if (retryCount === UPLOAD_MAX_RETRY_COUNT || !isRetryableUploadError(e, !!signal?.aborted)) {
|
||||
onError(lastError);
|
||||
fail(lastError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -265,14 +271,14 @@ export const uploadContent = async (
|
||||
await sleepForMs(waitMS);
|
||||
// Cancelled during the back-off — stop instead of resurrecting the upload.
|
||||
if (signal?.aborted) {
|
||||
onError(abortError());
|
||||
fail(abortError());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable in practice, but keeps onError guaranteed if the loop exits.
|
||||
if (lastError) onError(lastError);
|
||||
if (lastError) fail(lastError);
|
||||
};
|
||||
|
||||
export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
|
||||
@@ -467,15 +473,24 @@ export const declineInvite = async (mx: MatrixClient, roomId: string): Promise<v
|
||||
await mx.forget(roomId).catch(() => undefined);
|
||||
};
|
||||
|
||||
export type RateLimitedFailure<T> = { item: T; error: MatrixError };
|
||||
|
||||
/**
|
||||
* Run `callback` over `data` sequentially, backing off on 429. Other errors do
|
||||
* not stop the loop — they are collected and returned so callers can tell the
|
||||
* user which items failed (a failed /kick used to vanish silently, #216).
|
||||
*/
|
||||
export const rateLimitedActions = async <T, R = void>(
|
||||
data: T[],
|
||||
callback: (item: T, index: number) => Promise<R>,
|
||||
maxRetryCount?: number,
|
||||
) => {
|
||||
): Promise<{ failures: RateLimitedFailure<T>[] }> => {
|
||||
let retryCount = 0;
|
||||
|
||||
let actionInterval = 0;
|
||||
|
||||
const failures: RateLimitedFailure<T>[] = [];
|
||||
|
||||
const sleepForMs = (ms: number) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
@@ -486,6 +501,7 @@ export const rateLimitedActions = async <T, R = void>(
|
||||
|
||||
if (err?.httpStatus === 429) {
|
||||
if (retryCount === maxRetryCount) {
|
||||
failures.push({ item: dataItem, error: err });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -496,6 +512,8 @@ export const rateLimitedActions = async <T, R = void>(
|
||||
retryCount += 1;
|
||||
|
||||
await performAction(dataItem, index);
|
||||
} else if (err) {
|
||||
failures.push({ item: dataItem, error: err });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -509,6 +527,7 @@ export const rateLimitedActions = async <T, R = void>(
|
||||
await sleepForMs(actionInterval);
|
||||
}
|
||||
}
|
||||
return { failures };
|
||||
};
|
||||
|
||||
export const knockSupported = (version: string): boolean => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { describeUploadError } from './uploadError';
|
||||
|
||||
const err = (status: number | undefined, body: { errcode?: string; error?: string }) =>
|
||||
new MatrixError(body, status, 'http://hs/_matrix/media/v3/upload?filename=x.png');
|
||||
|
||||
test('413 names the limit when known and never leaks the URL', () => {
|
||||
const e = err(413, { errcode: 'M_TOO_LARGE', error: 'nope' });
|
||||
assert.equal(
|
||||
describeUploadError(e, 50 * 1024 * 1024),
|
||||
'This file is larger than the server allows (limit 52.4 MB).',
|
||||
);
|
||||
assert.equal(describeUploadError(e), 'This file is larger than the server allows.');
|
||||
assert.ok(!describeUploadError(e).includes('http'));
|
||||
});
|
||||
|
||||
test('429, 403, 5xx and transport failures each get their sentence', () => {
|
||||
assert.equal(
|
||||
describeUploadError(err(429, { errcode: 'M_LIMIT_EXCEEDED', error: 'Too Many Requests' })),
|
||||
'Slow down — try again in a moment.',
|
||||
);
|
||||
assert.equal(
|
||||
describeUploadError(err(403, { errcode: 'M_FORBIDDEN', error: 'Uploads disabled' })),
|
||||
'The server refused this upload: Uploads disabled',
|
||||
);
|
||||
assert.equal(
|
||||
describeUploadError(err(502, { errcode: 'M_UNKNOWN', error: 'Bad Gateway' })),
|
||||
"Couldn't reach the server. Check your connection and retry.",
|
||||
);
|
||||
assert.equal(
|
||||
describeUploadError(new MatrixError({ error: 'request failed' })),
|
||||
"Couldn't reach the server. Check your connection and retry.",
|
||||
);
|
||||
});
|
||||
|
||||
test('a cancel and an unknown 4xx fall through to the server text', () => {
|
||||
assert.equal(
|
||||
describeUploadError(new MatrixError({ error: 'Upload cancelled' })),
|
||||
'Upload cancelled.',
|
||||
);
|
||||
assert.equal(
|
||||
describeUploadError(err(400, { errcode: 'M_BAD_JSON', error: 'Unsupported file type' })),
|
||||
'Unsupported file type',
|
||||
);
|
||||
assert.equal(describeUploadError(err(400, {})), 'Upload failed.');
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { bytesToSize } from './common';
|
||||
|
||||
/**
|
||||
* [Gitea #213] One plain sentence for a failed upload. `MatrixError.message`
|
||||
* is the SDK's toString — "MatrixError: [413] nope (http://hs/_matrix/media/…)"
|
||||
* — which is what the card used to print. The raw error still goes to the
|
||||
* console for debugging.
|
||||
*/
|
||||
export const describeUploadError = (error: MatrixError, limitBytes?: number): string => {
|
||||
const status = error.httpStatus;
|
||||
const errcode = error.errcode;
|
||||
const serverText = (error.data as { error?: string } | undefined)?.error;
|
||||
|
||||
if (status === 413 || errcode === 'M_TOO_LARGE') {
|
||||
const limit =
|
||||
typeof limitBytes === 'number' && Number.isFinite(limitBytes)
|
||||
? ` (limit ${bytesToSize(limitBytes)})`
|
||||
: '';
|
||||
return `This file is larger than the server allows${limit}.`;
|
||||
}
|
||||
if (status === 429 || errcode === 'M_LIMIT_EXCEEDED') {
|
||||
return 'Slow down — try again in a moment.';
|
||||
}
|
||||
if (status === 401 || status === 403 || errcode === 'M_FORBIDDEN') {
|
||||
return serverText
|
||||
? `The server refused this upload: ${serverText}`
|
||||
: 'The server refused this upload.';
|
||||
}
|
||||
if (typeof status !== 'number' || status === 408 || status >= 500) {
|
||||
// Transport failure after the retry loop gave up, or a server-side error.
|
||||
if (serverText === 'Upload cancelled') return 'Upload cancelled.';
|
||||
return "Couldn't reach the server. Check your connection and retry.";
|
||||
}
|
||||
// Any other 4xx: the server's own sentence, never the URL-bearing toString.
|
||||
return serverText ? serverText.replace(/\s*\(https?:\/\/[^)]*\)\s*$/, '') : 'Upload failed.';
|
||||
};
|
||||
Reference in New Issue
Block a user