fix(direct): hide fresh bridge-ghost invites while the double puppet auto-accepts them

This commit is contained in:
heaven 2026-06-10 22:25:06 +03:00
parent a286d1e2c6
commit 7bf177751b
2 changed files with 48 additions and 1 deletions

View file

@ -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 `<network>_<remote id>` 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

View file

@ -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]);
};