fix(invites): decline invites robustly (no 500, no ghost, friendly error)
Declining a remote invite could show a raw 'MatrixError: [500] Internal server error' and appear to do nothing. Root causes were client-side: decline called mx.leave unconditionally, so re-clicking after a slow federated leave hit an already-left remote room that Synapse 500s on; the room was never forgotten so a 'leave' ghost lingered and re-invited a click; and the raw error string was shown. Add a shared declineInvite(mx, roomId) helper that only leaves when still in the room (invite/join/knock) and then forgets it (best-effort, first use of forget in the app). Route the InviteCard decline and both 'Decline All' paths through it, and replace the raw error with a friendly message (real error kept in console). Tests: declineInvite covered (6 cases); typecheck + full suite + build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ import { nameInitials } from '../../../utils/common';
|
|||||||
import { RoomAvatar } from '../../../components/room-avatar';
|
import { RoomAvatar } from '../../../components/room-avatar';
|
||||||
import {
|
import {
|
||||||
addRoomIdToMDirect,
|
addRoomIdToMDirect,
|
||||||
|
declineInvite,
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
guessDmRoomUserId,
|
guessDmRoomUserId,
|
||||||
rateLimitedActions,
|
rateLimitedActions,
|
||||||
@@ -179,8 +180,16 @@ function InviteCard({
|
|||||||
onNavigate(invite.roomId, invite.isSpace);
|
onNavigate(invite.roomId, invite.isSpace);
|
||||||
}, [mx, invite, userId, onNavigate]),
|
}, [mx, invite, userId, onNavigate]),
|
||||||
);
|
);
|
||||||
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
|
const [leaveState, leave] = useAsyncCallback<void, Error, []>(
|
||||||
useCallback(() => mx.leave(invite.roomId), [mx, invite]),
|
useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await declineInvite(mx, invite.roomId);
|
||||||
|
} catch (e) {
|
||||||
|
// Surface a friendly message; keep the real error in the console.
|
||||||
|
console.warn('Failed to decline invite', invite.roomId, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, [mx, invite.roomId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const joining =
|
const joining =
|
||||||
@@ -276,7 +285,7 @@ function InviteCard({
|
|||||||
)}
|
)}
|
||||||
{leaveState.status === AsyncStatus.Error && (
|
{leaveState.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
{leaveState.error.message}
|
Couldn’t decline this invite — please try again.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -481,7 +490,7 @@ function UnknownInvites({
|
|||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const roomIds = invites.map((invite) => invite.roomId);
|
const roomIds = invites.map((invite) => invite.roomId);
|
||||||
|
|
||||||
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
|
await rateLimitedActions(roomIds, (roomId) => declineInvite(mx, roomId));
|
||||||
}, [mx, invites]),
|
}, [mx, invites]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -559,7 +568,7 @@ function SpamInvites({
|
|||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const roomIds = invites.map((invite) => invite.roomId);
|
const roomIds = invites.map((invite) => invite.roomId);
|
||||||
|
|
||||||
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
|
await rateLimitedActions(roomIds, (roomId) => declineInvite(mx, roomId));
|
||||||
}, [mx, invites]),
|
}, [mx, invites]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { declineInvite } from './matrix';
|
||||||
|
|
||||||
|
// declineInvite must only leave when we're still in the room (invite/join/knock)
|
||||||
|
// — re-leaving an already-left remote room is what Synapse 500s on — and must
|
||||||
|
// always forget afterwards (best-effort) so the ghost invite can't linger.
|
||||||
|
const makeMx = (
|
||||||
|
membership: string | undefined,
|
||||||
|
opts: { leaveRejects?: boolean; forgetRejects?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const mx = {
|
||||||
|
getRoom: () => (membership === undefined ? null : { getMyMembership: () => membership }),
|
||||||
|
leave: async () => {
|
||||||
|
calls.push('leave');
|
||||||
|
if (opts.leaveRejects) throw new Error('leave failed');
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
forget: async () => {
|
||||||
|
calls.push('forget');
|
||||||
|
if (opts.forgetRejects) throw new Error('forget failed');
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
return { mx, calls };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('declineInvite: invite → leave then forget', async () => {
|
||||||
|
const { mx, calls } = makeMx('invite');
|
||||||
|
await declineInvite(mx, '!r:s');
|
||||||
|
assert.deepEqual(calls, ['leave', 'forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: already left → skips leave, still forgets (no double-leave 500)', async () => {
|
||||||
|
const { mx, calls } = makeMx('leave');
|
||||||
|
await declineInvite(mx, '!r:s');
|
||||||
|
assert.deepEqual(calls, ['forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: join → leave then forget', async () => {
|
||||||
|
const { mx, calls } = makeMx('join');
|
||||||
|
await declineInvite(mx, '!r:s');
|
||||||
|
assert.deepEqual(calls, ['leave', 'forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: no room object → only forget', async () => {
|
||||||
|
const { mx, calls } = makeMx(undefined);
|
||||||
|
await declineInvite(mx, '!r:s');
|
||||||
|
assert.deepEqual(calls, ['forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: forget failure is swallowed (best-effort)', async () => {
|
||||||
|
const { mx, calls } = makeMx('invite', { forgetRejects: true });
|
||||||
|
await declineInvite(mx, '!r:s'); // resolves despite forget throwing
|
||||||
|
assert.deepEqual(calls, ['leave', 'forget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declineInvite: genuine leave failure rejects', async () => {
|
||||||
|
const { mx } = makeMx('invite', { leaveRejects: true });
|
||||||
|
await assert.rejects(() => declineInvite(mx, '!r:s'), /leave failed/);
|
||||||
|
});
|
||||||
@@ -417,6 +417,24 @@ export const downloadEncryptedMedia = async (
|
|||||||
return decryptedContent;
|
return decryptedContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decline (reject) a room invite robustly. Only sends a leave when we're actually
|
||||||
|
* still in the room (invite/join/knock) — re-leaving an already-left *remote* room
|
||||||
|
* is exactly what Synapse 500s on — then forgets the room so a lingering "leave"
|
||||||
|
* ghost can't re-render as a clickable invite. Idempotent; forget is best-effort.
|
||||||
|
*/
|
||||||
|
export const declineInvite = async (mx: MatrixClient, roomId: string): Promise<void> => {
|
||||||
|
const membership = mx.getRoom(roomId)?.getMyMembership();
|
||||||
|
if (
|
||||||
|
membership === Membership.Invite ||
|
||||||
|
membership === Membership.Join ||
|
||||||
|
membership === Membership.Knock
|
||||||
|
) {
|
||||||
|
await mx.leave(roomId);
|
||||||
|
}
|
||||||
|
await mx.forget(roomId).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
export const rateLimitedActions = async <T, R = void>(
|
export const rateLimitedActions = async <T, R = void>(
|
||||||
data: T[],
|
data: T[],
|
||||||
callback: (item: T, index: number) => Promise<R>,
|
callback: (item: T, index: number) => Promise<R>,
|
||||||
|
|||||||
Reference in New Issue
Block a user