97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
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<string, boolean>();
|
||
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]);
|
||
};
|