From 29d74eda8fb96f3410072039b15c63b976daedc6 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 5 Jul 2026 11:55:35 -0400 Subject: [PATCH] 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 --- src/app/pages/client/inbox/Invites.tsx | 19 +++++-- src/app/utils/matrix.declineInvite.test.ts | 63 ++++++++++++++++++++++ src/app/utils/matrix.ts | 18 +++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 src/app/utils/matrix.declineInvite.test.ts diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index a0949b369..200395bdc 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -47,6 +47,7 @@ import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; import { addRoomIdToMDirect, + declineInvite, getMxIdLocalPart, guessDmRoomUserId, rateLimitedActions, @@ -179,8 +180,16 @@ function InviteCard({ onNavigate(invite.roomId, invite.isSpace); }, [mx, invite, userId, onNavigate]), ); - const [leaveState, leave] = useAsyncCallback, MatrixError, []>( - useCallback(() => mx.leave(invite.roomId), [mx, invite]), + const [leaveState, leave] = useAsyncCallback( + 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 = @@ -276,7 +285,7 @@ function InviteCard({ )} {leaveState.status === AsyncStatus.Error && ( - {leaveState.error.message} + Couldn’t decline this invite — please try again. )} @@ -481,7 +490,7 @@ function UnknownInvites({ useCallback(async () => { const roomIds = invites.map((invite) => invite.roomId); - await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId)); + await rateLimitedActions(roomIds, (roomId) => declineInvite(mx, roomId)); }, [mx, invites]), ); @@ -559,7 +568,7 @@ function SpamInvites({ useCallback(async () => { const roomIds = invites.map((invite) => invite.roomId); - await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId)); + await rateLimitedActions(roomIds, (roomId) => declineInvite(mx, roomId)); }, [mx, invites]), ); diff --git a/src/app/utils/matrix.declineInvite.test.ts b/src/app/utils/matrix.declineInvite.test.ts new file mode 100644 index 000000000..6a397213f --- /dev/null +++ b/src/app/utils/matrix.declineInvite.test.ts @@ -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/); +}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 7661b7527..c346ce538 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -417,6 +417,24 @@ export const downloadEncryptedMedia = async ( 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 => { + 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 ( data: T[], callback: (item: T, index: number) => Promise,