Files
cinny/src/app/pages/client/inbox/Invites.tsx
T

753 lines
24 KiB
TypeScript
Raw Normal View History

2025-05-24 20:07:56 +05:30
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Avatar,
2025-05-24 20:07:56 +05:30
Badge,
Box,
Button,
2025-05-24 20:07:56 +05:30
Chip,
Icon,
2024-08-03 19:17:53 +05:30
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
color,
config,
} from 'folds';
import { useAtomValue } from 'jotai';
2025-05-24 20:07:56 +05:30
import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
2025-05-24 20:07:56 +05:30
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 {
2025-05-24 20:07:56 +05:30
bannedInRooms,
getCommonRooms,
getDirectRoomAvatarUrl,
getMemberDisplayName,
getRoomAvatarUrl,
2025-05-24 20:07:56 +05:30
getStateEvent,
isDirectInvite,
2025-05-24 20:07:56 +05:30
isSpace,
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import { RoomAvatar } from '../../../components/room-avatar';
2025-05-24 20:07:56 +05:30
import {
addRoomIdToMDirect,
getMxIdLocalPart,
guessDmRoomUserId,
rateLimitedActions,
} from '../../../utils/matrix';
import { Time } from '../../../components/message';
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
2024-07-18 18:50:20 +05:30
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
2024-08-03 19:17:53 +05:30
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
2024-09-09 18:45:20 +10:00
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
2025-05-24 20:07:56 +05:30
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;
2025-05-24 20:07:56 +05:30
type InviteData = {
room: Room;
2025-05-24 20:07:56 +05:30
roomId: string;
roomName: string;
roomAvatar?: string;
roomTopic?: string;
roomAlias?: string;
senderId: string;
senderName: string;
inviteTs?: number;
isSpace: boolean;
isDirect: boolean;
isEncrypted: boolean;
};
2025-05-24 20:07:56 +05:30
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;
2025-05-24 20:07:56 +05:30
const roomTopic =
getStateEvent(room, StateEvent.RoomTopic)?.getContent<RoomTopicEventContent>()?.topic ??
undefined;
const member = room.getMember(userId);
const memberEvent = member?.events.member;
2025-05-24 20:07:56 +05:30
const senderId = memberEvent?.getSender();
const senderName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: undefined;
2025-05-24 20:07:56 +05:30
const inviteTs = memberEvent?.getTs() ?? 0;
return {
room,
roomId: room.roomId,
roomAvatar,
roomName,
roomTopic,
roomAlias: room.getCanonicalAlias() ?? undefined,
2025-05-24 20:07:56 +05:30
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<void, MatrixError, []>(
useCallback(async () => {
2025-05-24 20:07:56 +05:30
const dmUserId = isDirectInvite(invite.room, userId)
? guessDmRoomUserId(invite.room, userId)
: undefined;
2025-05-24 20:07:56 +05:30
await mx.joinRoom(invite.roomId);
if (dmUserId) {
2025-05-24 20:07:56 +05:30
await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
}
2025-05-24 20:07:56 +05:30
onNavigate(invite.roomId, invite.isSpace);
}, [mx, invite, userId, onNavigate])
);
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
2025-05-24 20:07:56 +05:30
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 (
<SequenceCard
variant="SurfaceVariant"
direction="Column"
2025-05-24 20:07:56 +05:30
gap="300"
style={{ padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}` }}
>
2025-05-24 20:07:56 +05:30
{(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
<Box gap="200" alignItems="Center">
{invite.isEncrypted && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Success" fill="Solid" size="400" radii="300">
<Text size="L400">Encrypted</Text>
</Badge>
</Box>
)}
{invite.isDirect && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Primary" fill="Solid" size="400" radii="300">
<Text size="L400">Direct Message</Text>
</Badge>
</Box>
)}
{invite.isSpace && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Secondary" fill="Soft" size="400" radii="300">
<Text size="L400">Space</Text>
</Badge>
</Box>
)}
</Box>
2025-05-24 20:07:56 +05:30
)}
<Box gap="300">
<Avatar size="300">
<RoomAvatar
2025-05-24 20:07:56 +05:30
roomId={invite.roomId}
src={hideAvatar ? undefined : invite.roomAvatar}
alt={invite.roomName}
renderFallback={() => (
<Text as="span" size="H6">
2025-05-24 20:07:56 +05:30
{nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
</Text>
)}
/>
</Avatar>
<Box direction={compact ? 'Column' : 'Row'} grow="Yes" gap="200">
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column">
<Text size="T300" truncate>
2025-05-24 20:07:56 +05:30
<b>{invite.roomName}</b>
</Text>
2025-05-24 20:07:56 +05:30
{invite.roomTopic && (
<Text
size="T200"
onClick={openTopic}
onKeyDown={onEnterOrSpace(openTopic)}
tabIndex={0}
truncate
>
2025-05-24 20:07:56 +05:30
{invite.roomTopic}
</Text>
)}
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeTopic,
2024-07-18 18:50:20 +05:30
escapeDeactivates: stopPropagation,
}}
>
<RoomTopicViewer
2025-05-24 20:07:56 +05:30
name={invite.roomName}
topic={invite.roomTopic ?? ''}
requestClose={closeTopic}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
</Box>
{joinState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{joinState.error.message}
</Text>
)}
{leaveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{leaveState.error.message}
</Text>
)}
</Box>
<Box gap="200" shrink="No" alignItems="Center">
<Button
onClick={leave}
size="300"
variant="Secondary"
2025-05-24 20:07:56 +05:30
radii="300"
fill="Soft"
disabled={joining || leaving}
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
>
<Text size="B300">Decline</Text>
</Button>
<Button
onClick={join}
size="300"
2025-05-24 20:07:56 +05:30
variant="Success"
fill="Soft"
2025-05-24 20:07:56 +05:30
radii="300"
outlined
disabled={joining || leaving}
2025-05-24 20:07:56 +05:30
before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
>
<Text size="B300">Accept</Text>
</Button>
</Box>
</Box>
</Box>
2025-05-24 20:07:56 +05:30
<Box gap="200" alignItems="Baseline">
<Box grow="Yes">
<Text size="T200" priority="300">
From: <b>{invite.senderId}</b>
</Text>
</Box>
{invite.inviteTs && (
<Box shrink="No">
<Time size="T200" ts={invite.inviteTs} priority="300" />
</Box>
)}
</Box>
</SequenceCard>
);
}
2025-05-24 20:07:56 +05:30
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 (
<Box gap="200">
<Chip
variant={isKnown ? 'Success' : 'Surface'}
aria-selected={isKnown}
outlined={!isKnown}
onClick={() => onFilter(InviteFilter.Known)}
before={isKnown && <Icon size="100" src={Icons.Check} />}
after={
knownInvites.length > 0 && (
<Badge variant={isKnown ? 'Success' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{knownInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Primary</Text>
</Chip>
<Chip
variant={isUnknown ? 'Warning' : 'Surface'}
aria-selected={isUnknown}
outlined={!isUnknown}
onClick={() => onFilter(InviteFilter.Unknown)}
before={isUnknown && <Icon size="100" src={Icons.Check} />}
after={
unknownInvites.length > 0 && (
<Badge variant={isUnknown ? 'Warning' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{unknownInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Public</Text>
</Chip>
<Chip
variant={isSpam ? 'Critical' : 'Surface'}
aria-selected={isSpam}
outlined={!isSpam}
onClick={() => onFilter(InviteFilter.Spam)}
before={isSpam && <Icon size="100" src={Icons.Check} />}
after={
spamInvites.length > 0 && (
<Badge variant={isSpam ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{spamInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Spam</Text>
</Chip>
</Box>
);
}
type KnownInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
};
function KnownInvites({ invites, handleNavigate, compact }: KnownInvitesProps) {
return (
<Box direction="Column" gap="200">
<Text size="H4">Primary</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
onNavigate={handleNavigate}
hideAvatar={false}
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Mail} />}
title="No Invites"
subTitle="When someone you share a room with sends you an invite, itll show up here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
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 (
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween" alignItems="Center">
<Text size="H4">Public</Text>
<Box>
{invites.length > 0 && (
<Chip
variant="SurfaceVariant"
onClick={declineAll}
before={declining && <Spinner size="50" variant="Secondary" fill="Soft" />}
disabled={declining}
radii="Pill"
>
<Text size="T200">Decline All</Text>
</Chip>
)}
2025-05-24 20:07:56 +05:30
</Box>
</Box>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
onNavigate={handleNavigate}
hideAvatar
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Info} />}
title="No Invites"
subTitle="Invites from people outside your rooms will appear here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
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 (
<Box direction="Column" gap="200">
<Text size="H4">Spam</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
<SequenceCard
variant="SurfaceVariant"
direction="Column"
gap="300"
style={{ padding: `${config.space.S400} ${config.space.S400} 0` }}
>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Warning} />}
title={`${invites.length} Spam Invites`}
subTitle="Some of the following invites may contain harmful content or have been sent by banned users."
>
<Box direction="Row" gap="200" justifyContent="Center" wrap="Wrap">
<Button
size="300"
variant="Critical"
fill="Solid"
radii="300"
onClick={declineAll}
before={declining && <Spinner size="100" variant="Critical" fill="Solid" />}
disabled={loading}
>
<Text size="B300" truncate>
Decline All
</Text>
</Button>
{reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
onClick={reportAll}
before={reporting && <Spinner size="100" variant="Secondary" fill="Solid" />}
disabled={loading}
>
<Text size="B300" truncate>
Report All
</Text>
</Button>
)}
{unignoredUsers.length > 0 && (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
disabled={loading}
onClick={blockAll}
before={blocking && <Spinner size="100" variant="Secondary" fill="Solid" />}
>
<Text size="B300" truncate>
Block All
</Text>
</Button>
)}
</Box>
<span data-spacing-node />
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="Pill"
before={
<Icon size="100" src={showInvites ? Icons.ChevronTop : Icons.ChevronBottom} />
}
onClick={() => setShowInvites(!showInvites)}
>
<Text size="B300">{showInvites ? 'Hide All' : 'View All'}</Text>
</Button>
</PageHero>
</PageHeroSection>
</SequenceCard>
{showInvites &&
invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
onNavigate={handleNavigate}
hideAvatar
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Warning} />}
title="No Spam Invites"
subTitle="Invites detected as spam appear here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
export function Invites() {
const mx = useMatrixClient();
2025-05-24 20:07:56 +05:30
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<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
useElementSizeObserver(
useCallback(() => containerRef.current, []),
useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), [])
);
2024-08-03 19:17:53 +05:30
const screenSize = useScreenSizeContext();
2025-05-24 20:07:56 +05:30
const handleNavigate = (roomId: string, space: boolean) => {
if (space) {
navigateSpace(roomId);
return;
}
navigateRoom(roomId);
};
return (
<Page>
2024-08-03 19:17:53 +05:30
<PageHeader balance>
<Box grow="Yes" gap="200">
<Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
<Text size="H3" truncate>
2025-05-24 20:07:56 +05:30
Invites
2024-08-03 19:17:53 +05:30
</Text>
</Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Box ref={containerRef} direction="Column" gap="600">
2025-05-24 20:07:56 +05:30
<Box direction="Column" gap="100">
<span data-spacing-node />
<Text size="L400">Filter</Text>
<InviteFilters
filter={filter}
onFilter={setFilter}
knownInvites={knownInvites}
unknownInvites={unknownInvites}
spamInvites={spamInvites}
/>
</Box>
{filter === InviteFilter.Known && (
<KnownInvites
invites={knownInvites}
compact={compact}
handleNavigate={handleNavigate}
/>
)}
2025-05-24 20:07:56 +05:30
{filter === InviteFilter.Unknown && (
<UnknownInvites
invites={unknownInvites}
compact={compact}
handleNavigate={handleNavigate}
/>
)}
2025-05-24 20:07:56 +05:30
{filter === InviteFilter.Spam && (
<SpamInvites
invites={spamInvites}
compact={compact}
handleNavigate={handleNavigate}
/>
)}
</Box>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}