diff --git a/src/app/features/bots/room.ts b/src/app/features/bots/room.ts index 5a2636ed..cb3c4fae 100644 --- a/src/app/features/bots/room.ts +++ b/src/app/features/bots/room.ts @@ -64,6 +64,43 @@ export const isCatalogBotControlRoom = ( presets: readonly BotPreset[] ): boolean => presets.some((preset) => isBotControlRoom(mx, room, preset)); +// How long a bridge-ghost invite stays hidden from the Direct invite list +// while we expect the bridge's double puppet to auto-accept it. Long enough +// to cover a busy initial chat sync; short enough that a broken double +// puppet leaves the user a manual Accept button instead of a black hole. +export const BRIDGE_GHOST_INVITE_GRACE_MS = 2 * 60 * 1000; + +// «Is this pending invite from a bridge ghost puppet of a catalog bot?» +// mautrix ghost mxids follow the `_` localpart +// convention and the catalog bot ids ARE the network slugs (telegram / +// whatsapp / discord — config.json is the trusted source), so the prefix +// match needs no client-side knowledge beyond the operator config. Invite +// rooms carry only stripped state — m.bridge is NOT in it — which is why +// this matches on the inviter mxid instead of `isBridgedRoom`. +// +// Used to suppress the invite-row flicker during bridge sync: the bridge's +// double puppet auto-accepts portal invites within seconds, and rendering +// Accept/Decline for something that resolves itself reads as UI noise. Only +// FRESH invites (younger than the grace window) are hidden — if auto-accept +// is broken, the invite resurfaces with manual controls. +export const isFreshBridgeGhostInvite = ( + mx: MatrixClient, + room: Room, + presets: readonly BotPreset[] +): boolean => { + const myUserId = mx.getUserId(); + if (!myUserId) return false; + const memberEvent = room.getMember(myUserId)?.events.member; + const inviter = memberEvent?.getSender(); + if (!inviter) return false; + const ts = memberEvent?.getTs() ?? 0; + if (Date.now() - ts > BRIDGE_GHOST_INVITE_GRACE_MS) return false; + return presets.some((preset) => { + const botServer = preset.mxid.slice(preset.mxid.indexOf(':')); + return inviter.startsWith(`@${preset.id}_`) && inviter.endsWith(botServer); + }); +}; + // Returns the BotPreset that owns this room, or undefined if the room is // not a bot control DM. Used by the RoomMenu's «Show widget» item to // know which `/bots/:id` route to navigate to. Wraps the same matcher as diff --git a/src/app/pages/client/direct/useDirectInvites.ts b/src/app/pages/client/direct/useDirectInvites.ts index 374684ca..bc021079 100644 --- a/src/app/pages/client/direct/useDirectInvites.ts +++ b/src/app/pages/client/direct/useDirectInvites.ts @@ -10,6 +10,8 @@ import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { getMxIdLocalPart } from '../../../utils/matrix'; import { StateEvent } from '../../../../types/matrix/room'; +import { useBotPresets } from '../../../features/bots/catalog'; +import { isFreshBridgeGhostInvite } from '../../../features/bots/room'; export type DirectInviteEntry = { room: Room; @@ -53,6 +55,7 @@ export const useDirectInvites = (): DirectInviteEntry[] => { const inviteIds = useAtomValue(allInvitesAtom); const allRooms = useAtomValue(allRoomsAtom); const [spamFilterEnabled] = useSetting(settingsAtom, 'inviteSpamFilter'); + const bots = useBotPresets(); const myUserId = mx.getSafeUserId(); return useMemo(() => { @@ -73,6 +76,13 @@ export const useDirectInvites = (): DirectInviteEntry[] => { inviteIds.forEach((roomId) => { const room = mx.getRoom(roomId); if (!room) return; + // Bridge portal invites get auto-accepted by the bridge's double + // puppet within seconds of arriving — rendering Accept/Decline rows + // that vanish on their own reads as flicker, especially during the + // post-login burst when the bridge syncs dozens of chats. Fresh ghost + // invites are suppressed; stale ones (auto-accept broken) fall + // through and stay actionable. + if (isFreshBridgeGhostInvite(mx, room, bots)) return; const me = room.getMember(myUserId); const senderId = me?.events.member?.getSender() ?? ''; // Moderation signal — sender is banned in a room we share. This is a @@ -93,5 +103,5 @@ export const useDirectInvites = (): DirectInviteEntry[] => { }); out.sort((a, b) => b.ts - a.ts); return out; - }, [mx, inviteIds, allRooms, myUserId, spamFilterEnabled]); + }, [mx, inviteIds, allRooms, myUserId, spamFilterEnabled, bots]); };