import { useAtomValue } from 'jotai'; import { useMemo } from 'react'; import { Room } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { allInvitesAtom } from '../../../state/room-list/inviteList'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { bannedInRooms, getMemberDisplayName, getStateEvent } from '../../../utils/room'; import { testBadWords } from '../../../plugins/bad-words'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { getMxIdLocalPart } from '../../../utils/matrix'; import { StateEvent } from '../../../../types/matrix/room'; export type DirectInviteEntry = { room: Room; roomId: string; ts: number; isSpam: boolean; }; const getInviteTs = (room: Room, myUserId: string): number => { const me = room.getMember(myUserId); return me?.events.member?.getTs() ?? 0; }; const inviteHasBadWords = (room: Room, myUserId: string): boolean => { const roomName = room.name || ''; const topic = getStateEvent(room, StateEvent.RoomTopic)?.getContent<{ topic?: string }>()?.topic ?? ''; const me = room.getMember(myUserId); const memberEvent = me?.events.member; const senderId = memberEvent?.getSender() ?? ''; const senderName = senderId ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId : ''; const reasonContent = memberEvent?.getContent(); const reason = reasonContent && 'reason' in reasonContent && typeof reasonContent.reason === 'string' ? reasonContent.reason : ''; return ( testBadWords(roomName) || testBadWords(topic) || testBadWords(senderName) || testBadWords(senderId) || testBadWords(reason) ); }; export const useDirectInvites = (): DirectInviteEntry[] => { const mx = useMatrixClient(); const inviteIds = useAtomValue(allInvitesAtom); const allRooms = useAtomValue(allRoomsAtom); const [spamFilterEnabled] = useSetting(settingsAtom, 'inviteSpamFilter'); const myUserId = mx.getSafeUserId(); return useMemo(() => { // Cache `bannedInRooms` per senderId — a single inviter MXID can be the // sender of multiple invite rooms, and bannedInRooms iterates every // joined room. Without the cache we'd be O(invites × joinedRooms) on // every recompute. const bannedCache = new Map(); const isSenderBanned = (senderId: string): boolean => { const cached = bannedCache.get(senderId); if (cached !== undefined) return cached; const banned = bannedInRooms(mx, allRooms, senderId); bannedCache.set(senderId, banned); return banned; }; const out: DirectInviteEntry[] = []; inviteIds.forEach((roomId) => { const room = mx.getRoom(roomId); if (!room) 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 // server-side moderator's verdict, not a personal preference, so it's // ALWAYS classified as spam regardless of the user-facing toggle. const moderationSpam = !!senderId && isSenderBanned(senderId); // Lexical signal — bad-words match in the room name / topic / sender / // reason. This is heuristic and noisy, so it's gated on the user-facing // `inviteSpamFilter` toggle for users who'd rather see everything raw. const lexicalSpam = spamFilterEnabled && inviteHasBadWords(room, myUserId); const isSpam = moderationSpam || lexicalSpam; out.push({ room, roomId, ts: getInviteTs(room, myUserId), isSpam, }); }); out.sort((a, b) => b.ts - a.ts); return out; }, [mx, inviteIds, allRooms, myUserId, spamFilterEnabled]); };