import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Avatar, Badge, Box, Button, Chip, Icon, IconButton, Icons, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Spinner, Text, color, config, } from 'folds'; import { useAtomValue } from 'jotai'; import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk'; import { Page, PageContent, PageContentCenter, PageHeader, PageHero, PageHeroEmpty, PageHeroSection, } from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { allInvitesAtom } from '../../../state/room-list/inviteList'; import { SequenceCard } from '../../../components/sequence-card'; import { bannedInRooms, getCommonRooms, getDirectRoomAvatarUrl, getMemberDisplayName, getRoomAvatarUrl, getStateEvent, isDirectInvite, isSpace, } from '../../../utils/room'; import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId, rateLimitedActions, } from '../../../utils/matrix'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { StateEvent } from '../../../../types/matrix/room'; import { testBadWords } from '../../../plugins/bad-words'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported'; const COMPACT_CARD_WIDTH = 548; type InviteData = { room: Room; roomId: string; roomName: string; roomAvatar?: string; roomTopic?: string; roomAlias?: string; senderId: string; senderName: string; inviteTs?: number; isSpace: boolean; isDirect: boolean; isEncrypted: boolean; }; const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => { const userId = mx.getSafeUserId(); const direct = isDirectInvite(room, userId); const roomAvatar = direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication); const roomName = room.name || room.getCanonicalAlias() || room.roomId; const roomTopic = getStateEvent(room, StateEvent.RoomTopic)?.getContent()?.topic ?? undefined; const member = room.getMember(userId); const memberEvent = member?.events.member; const senderId = memberEvent?.getSender(); const senderName = senderId ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId : undefined; const inviteTs = memberEvent?.getTs() ?? 0; return { room, roomId: room.roomId, roomAvatar, roomName, roomTopic, roomAlias: room.getCanonicalAlias() ?? undefined, senderId: senderId ?? 'Unknown', senderName: senderName ?? 'Unknown', inviteTs, isSpace: isSpace(room), isDirect: direct, isEncrypted: !!getStateEvent(room, StateEvent.RoomEncryption), }; }; const hasBadWords = (invite: InviteData): boolean => testBadWords(invite.roomName) || testBadWords(invite.roomTopic ?? '') || testBadWords(invite.senderName) || testBadWords(invite.senderId); type NavigateHandler = (roomId: string, space: boolean) => void; type InviteCardProps = { invite: InviteData; compact?: boolean; onNavigate: NavigateHandler; hideAvatar: boolean; }; function InviteCard({ invite, compact, onNavigate, hideAvatar }: InviteCardProps) { const mx = useMatrixClient(); const userId = mx.getSafeUserId(); const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); const [joinState, join] = useAsyncCallback( useCallback(async () => { const dmUserId = isDirectInvite(invite.room, userId) ? guessDmRoomUserId(invite.room, userId) : undefined; await mx.joinRoom(invite.roomId); if (dmUserId) { await addRoomIdToMDirect(mx, invite.roomId, dmUserId); } onNavigate(invite.roomId, invite.isSpace); }, [mx, invite, userId, onNavigate]) ); const [leaveState, leave] = useAsyncCallback, MatrixError, []>( useCallback(() => mx.leave(invite.roomId), [mx, invite]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; const leaving = leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success; return ( {(invite.isEncrypted || invite.isDirect || invite.isSpace) && ( {invite.isEncrypted && ( Encrypted )} {invite.isDirect && ( Direct Message )} {invite.isSpace && ( Space )} )} ( {nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)} )} /> {invite.roomName} {invite.roomTopic && ( {invite.roomTopic} )} }> {joinState.status === AsyncStatus.Error && ( {joinState.error.message} )} {leaveState.status === AsyncStatus.Error && ( {leaveState.error.message} )} From: {invite.senderId} {invite.inviteTs && ( )} ); } enum InviteFilter { Known, Unknown, Spam, } type InviteFiltersProps = { filter: InviteFilter; onFilter: (filter: InviteFilter) => void; knownInvites: InviteData[]; unknownInvites: InviteData[]; spamInvites: InviteData[]; }; function InviteFilters({ filter, onFilter, knownInvites, unknownInvites, spamInvites, }: InviteFiltersProps) { const isKnown = filter === InviteFilter.Known; const isUnknown = filter === InviteFilter.Unknown; const isSpam = filter === InviteFilter.Spam; return ( onFilter(InviteFilter.Known)} before={isKnown && } after={ knownInvites.length > 0 && ( {knownInvites.length} ) } > Primary onFilter(InviteFilter.Unknown)} before={isUnknown && } after={ unknownInvites.length > 0 && ( {unknownInvites.length} ) } > Public onFilter(InviteFilter.Spam)} before={isSpam && } after={ spamInvites.length > 0 && ( {spamInvites.length} ) } > Spam ); } type KnownInvitesProps = { invites: InviteData[]; handleNavigate: NavigateHandler; compact: boolean; }; function KnownInvites({ invites, handleNavigate, compact }: KnownInvitesProps) { return ( Primary {invites.length > 0 ? ( {invites.map((invite) => ( ))} ) : ( } title="No Invites" subTitle="When someone you share a room with sends you an invite, it’ll show up here." /> )} ); } type UnknownInvitesProps = { invites: InviteData[]; handleNavigate: NavigateHandler; compact: boolean; }; function UnknownInvites({ invites, handleNavigate, compact }: UnknownInvitesProps) { const mx = useMatrixClient(); const [declineAllStatus, declineAll] = useAsyncCallback( useCallback(async () => { const roomIds = invites.map((invite) => invite.roomId); await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId)); }, [mx, invites]) ); const declining = declineAllStatus.status === AsyncStatus.Loading; return ( Public {invites.length > 0 && ( } disabled={declining} radii="Pill" > Decline All )} {invites.length > 0 ? ( {invites.map((invite) => ( ))} ) : ( } title="No Invites" subTitle="Invites from people outside your rooms will appear here." /> )} ); } type SpamInvitesProps = { invites: InviteData[]; handleNavigate: NavigateHandler; compact: boolean; }; function SpamInvites({ invites, handleNavigate, compact }: SpamInvitesProps) { const mx = useMatrixClient(); const [showInvites, setShowInvites] = useState(false); const reportRoomSupported = useReportRoomSupported(); const [declineAllStatus, declineAll] = useAsyncCallback( useCallback(async () => { const roomIds = invites.map((invite) => invite.roomId); await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId)); }, [mx, invites]) ); const [reportAllStatus, reportAll] = useAsyncCallback( useCallback(async () => { const roomIds = invites.map((invite) => invite.roomId); await rateLimitedActions(roomIds, (roomId) => mx.reportRoom(roomId, 'Spam Invite')); }, [mx, invites]) ); const ignoredUsers = useIgnoredUsers(); const unignoredUsers = Array.from(new Set(invites.map((invite) => invite.senderId))).filter( (user) => !ignoredUsers.includes(user) ); const [blockAllStatus, blockAll] = useAsyncCallback( useCallback( () => mx.setIgnoredUsers([...ignoredUsers, ...unignoredUsers]), [mx, ignoredUsers, unignoredUsers] ) ); const declining = declineAllStatus.status === AsyncStatus.Loading; const reporting = reportAllStatus.status === AsyncStatus.Loading; const blocking = blockAllStatus.status === AsyncStatus.Loading; const loading = blocking || reporting || declining; return ( Spam {invites.length > 0 ? ( } title={`${invites.length} Spam Invites`} subTitle="Some of the following invites may contain harmful content or have been sent by banned users." > {reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && ( )} {unignoredUsers.length > 0 && ( )} {showInvites && invites.map((invite) => ( ))} ) : ( } title="No Spam Invites" subTitle="Invites detected as spam appear here." /> )} ); } export function Invites() { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom, navigateSpace } = useRoomNavigate(); const allRooms = useAtomValue(allRoomsAtom); const allInviteIds = useAtomValue(allInvitesAtom); const [filter, setFilter] = useState(InviteFilter.Known); const invitesData = allInviteIds .map((inviteId) => mx.getRoom(inviteId)) .filter((inviteRoom) => !!inviteRoom) .map((inviteRoom) => makeInviteData(mx, inviteRoom, useAuthentication)); const [knownInvites, unknownInvites, spamInvites] = useMemo(() => { const known: InviteData[] = []; const unknown: InviteData[] = []; const spam: InviteData[] = []; invitesData.forEach((invite) => { if (hasBadWords(invite) || bannedInRooms(mx, allRooms, invite.senderId)) { spam.push(invite); return; } if (getCommonRooms(mx, allRooms, invite.senderId).length === 0) { unknown.push(invite); return; } known.push(invite); }); return [known, unknown, spam]; }, [mx, allRooms, invitesData]); const containerRef = useRef(null); const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH); useElementSizeObserver( useCallback(() => containerRef.current, []), useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), []) ); const screenSize = useScreenSizeContext(); const handleNavigate = (roomId: string, space: boolean) => { if (space) { navigateSpace(roomId); return; } navigateRoom(roomId); }; return ( {screenSize === ScreenSize.Mobile && ( {(onBack) => ( )} )} {screenSize !== ScreenSize.Mobile && } Invites Filter {filter === InviteFilter.Known && ( )} {filter === InviteFilter.Unknown && ( )} {filter === InviteFilter.Spam && ( )} ); }