"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0",
+ "badwords-list": "2.0.1-4",
"blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
+ "node_modules/badwords-list": {
+ "version": "2.0.1-4",
+ "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz",
+ "integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==",
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0",
+ "badwords-list": "2.0.1-4",
"blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
));
+export function PageHeroEmpty({ children }: { children: ReactNode }) {
+ return (
+ <Box
+ className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
+ direction="Column"
+ alignItems="Center"
+ justifyContent="Center"
+ gap="200"
+ >
+ {children}
+ </Box>
+ );
+}
+
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box
},
]);
+export const PageHeroEmpty = style([
+ DefaultReset,
+ {
+ padding: config.space.S400,
+ borderRadius: config.radii.R400,
+ minHeight: toRem(450),
+ },
+]);
+
export const PageHeroSection = style([
DefaultReset,
{
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { SearchOrderBy } from 'matrix-js-sdk';
-import { PageHero, PageHeroSection } from '../../components/page';
+import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { _SearchPathSearchParams } from '../../pages/paths';
import { useSetting } from '../../state/hooks/settings';
</Box>
{!msgSearchParams.term && status === 'pending' && (
- <Box
- className={ContainerColor({ variant: 'SurfaceVariant' })}
- style={{
- padding: config.space.S400,
- borderRadius: config.radii.R400,
- minHeight: toRem(450),
- }}
- direction="Column"
- alignItems="Center"
- justifyContent="Center"
- gap="200"
- >
+ <PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Message} />}
subTitle="Find helpful messages in your community by searching with related keywords."
/>
</PageHeroSection>
- </Box>
+ </PageHeroEmpty>
)}
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
-import React, {
- ChangeEventHandler,
- FormEventHandler,
- useCallback,
- useEffect,
- useMemo,
- useState,
-} from 'react';
-import {
- Box,
- Text,
- IconButton,
- Icon,
- Icons,
- Scroll,
- Input,
- Avatar,
- Button,
- Chip,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Modal,
- Dialog,
- Header,
- config,
- Spinner,
-} from 'folds';
-import FocusTrap from 'focus-trap-react';
+import React from 'react';
+import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
-import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
-import { UserAvatar } from '../../../components/user-avatar';
-import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
-import { nameInitials } from '../../../utils/common';
-import { copyToClipboard } from '../../../utils/dom';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useFilePicker } from '../../../hooks/useFilePicker';
-import { useObjectURL } from '../../../hooks/useObjectURL';
-import { stopPropagation } from '../../../utils/keyboard';
-import { ImageEditor } from '../../../components/image-editor';
-import { ModalWide } from '../../../styles/Modal.css';
-import { createUploadAtom, UploadSuccess } from '../../../state/upload';
-import { CompactUploadCardRenderer } from '../../../components/upload-card';
-import { useCapabilities } from '../../../hooks/useCapabilities';
-
-function MatrixId() {
- const mx = useMatrixClient();
- const userId = mx.getUserId()!;
-
- return (
- <Box direction="Column" gap="100">
- <Text size="L400">Matrix ID</Text>
- <SequenceCard
- className={SequenceCardStyle}
- variant="SurfaceVariant"
- direction="Column"
- gap="400"
- >
- <SettingTile
- title={userId}
- after={
- <Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
- <Text size="T200">Copy</Text>
- </Chip>
- }
- />
- </SequenceCard>
- </Box>
- );
-}
-
-type ProfileProps = {
- profile: UserProfile;
- userId: string;
-};
-function ProfileAvatar({ profile, userId }: ProfileProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const capabilities = useCapabilities();
- const [alertRemove, setAlertRemove] = useState(false);
- const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
-
- const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
- const avatarUrl = profile.avatarUrl
- ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
- : undefined;
-
- const [imageFile, setImageFile] = useState<File>();
- const imageFileURL = useObjectURL(imageFile);
- const uploadAtom = useMemo(() => {
- if (imageFile) return createUploadAtom(imageFile);
- return undefined;
- }, [imageFile]);
-
- const pickFile = useFilePicker(setImageFile, false);
-
- const handleRemoveUpload = useCallback(() => {
- setImageFile(undefined);
- }, []);
-
- const handleUploaded = useCallback(
- (upload: UploadSuccess) => {
- const { mxc } = upload;
- mx.setAvatarUrl(mxc);
- handleRemoveUpload();
- },
- [mx, handleRemoveUpload]
- );
-
- const handleRemoveAvatar = () => {
- mx.setAvatarUrl('');
- setAlertRemove(false);
- };
-
- return (
- <SettingTile
- title={
- <Text as="span" size="L400">
- Avatar
- </Text>
- }
- after={
- <Avatar size="500" radii="300">
- <UserAvatar
- userId={userId}
- src={avatarUrl}
- renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
- />
- </Avatar>
- }
- >
- {uploadAtom ? (
- <Box gap="200" direction="Column">
- <CompactUploadCardRenderer
- uploadAtom={uploadAtom}
- onRemove={handleRemoveUpload}
- onComplete={handleUploaded}
- />
- </Box>
- ) : (
- <Box gap="200">
- <Button
- onClick={() => pickFile('image/*')}
- size="300"
- variant="Secondary"
- fill="Soft"
- outlined
- radii="300"
- disabled={disableSetAvatar}
- >
- <Text size="B300">Upload</Text>
- </Button>
- {avatarUrl && (
- <Button
- size="300"
- variant="Critical"
- fill="None"
- radii="300"
- disabled={disableSetAvatar}
- onClick={() => setAlertRemove(true)}
- >
- <Text size="B300">Remove</Text>
- </Button>
- )}
- </Box>
- )}
-
- {imageFileURL && (
- <Overlay open={false} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: handleRemoveUpload,
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- <Modal className={ModalWide} variant="Surface" size="500">
- <ImageEditor
- name={imageFile?.name ?? 'Unnamed'}
- url={imageFileURL}
- requestClose={handleRemoveUpload}
- />
- </Modal>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- )}
-
- <Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
- <OverlayCenter>
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setAlertRemove(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
- <Dialog variant="Surface">
- <Header
- style={{
- padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
- borderBottomWidth: config.borderWidth.B300,
- }}
- variant="Surface"
- size="500"
- >
- <Box grow="Yes">
- <Text size="H4">Remove Avatar</Text>
- </Box>
- <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
- <Icon src={Icons.Cross} />
- </IconButton>
- </Header>
- <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
- <Box direction="Column" gap="200">
- <Text priority="400">Are you sure you want to remove profile avatar?</Text>
- </Box>
- <Button variant="Critical" onClick={handleRemoveAvatar}>
- <Text size="B400">Remove</Text>
- </Button>
- </Box>
- </Dialog>
- </FocusTrap>
- </OverlayCenter>
- </Overlay>
- </SettingTile>
- );
-}
-
-function ProfileDisplayName({ profile, userId }: ProfileProps) {
- const mx = useMatrixClient();
- const capabilities = useCapabilities();
- const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
-
- const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
- const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
-
- const [changeState, changeDisplayName] = useAsyncCallback(
- useCallback((name: string) => mx.setDisplayName(name), [mx])
- );
- const changingDisplayName = changeState.status === AsyncStatus.Loading;
-
- useEffect(() => {
- setDisplayName(defaultDisplayName);
- }, [defaultDisplayName]);
-
- const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
- const name = evt.currentTarget.value;
- setDisplayName(name);
- };
-
- const handleReset = () => {
- setDisplayName(defaultDisplayName);
- };
-
- const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
- evt.preventDefault();
- if (changingDisplayName) return;
-
- const target = evt.target as HTMLFormElement | undefined;
- const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
- const name = displayNameInput?.value;
- if (!name) return;
-
- changeDisplayName(name);
- };
-
- const hasChanges = displayName !== defaultDisplayName;
- return (
- <SettingTile
- title={
- <Text as="span" size="L400">
- Display Name
- </Text>
- }
- >
- <Box direction="Column" grow="Yes" gap="100">
- <Box
- as="form"
- onSubmit={handleSubmit}
- gap="200"
- aria-disabled={changingDisplayName || disableSetDisplayname}
- >
- <Box grow="Yes" direction="Column">
- <Input
- required
- name="displayNameInput"
- value={displayName}
- onChange={handleChange}
- variant="Secondary"
- radii="300"
- style={{ paddingRight: config.space.S200 }}
- readOnly={changingDisplayName || disableSetDisplayname}
- after={
- hasChanges &&
- !changingDisplayName && (
- <IconButton
- type="reset"
- onClick={handleReset}
- size="300"
- radii="300"
- variant="Secondary"
- >
- <Icon src={Icons.Cross} size="100" />
- </IconButton>
- )
- }
- />
- </Box>
- <Button
- size="400"
- variant={hasChanges ? 'Success' : 'Secondary'}
- fill={hasChanges ? 'Solid' : 'Soft'}
- outlined
- radii="300"
- disabled={!hasChanges || changingDisplayName}
- type="submit"
- >
- {changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
- <Text size="B400">Save</Text>
- </Button>
- </Box>
- </Box>
- </SettingTile>
- );
-}
-
-function Profile() {
- const mx = useMatrixClient();
- const userId = mx.getUserId()!;
- const profile = useUserProfile(userId);
-
- return (
- <Box direction="Column" gap="100">
- <Text size="L400">Profile</Text>
- <SequenceCard
- className={SequenceCardStyle}
- variant="SurfaceVariant"
- direction="Column"
- gap="400"
- >
- <ProfileAvatar userId={userId} profile={profile} />
- <ProfileDisplayName userId={userId} profile={profile} />
- </SequenceCard>
- </Box>
- );
-}
-
-function ContactInformation() {
- const mx = useMatrixClient();
- const [threePIdsState, loadThreePIds] = useAsyncCallback(
- useCallback(() => mx.getThreePids(), [mx])
- );
- const threePIds =
- threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
-
- const emailIds = threePIds?.filter((id) => id.medium === 'email');
-
- useEffect(() => {
- loadThreePIds();
- }, [loadThreePIds]);
-
- return (
- <Box direction="Column" gap="100">
- <Text size="L400">Contact Information</Text>
- <SequenceCard
- className={SequenceCardStyle}
- variant="SurfaceVariant"
- direction="Column"
- gap="400"
- >
- <SettingTile title="Email Address" description="Email address attached to your account.">
- <Box>
- {emailIds?.map((email) => (
- <Chip key={email.address} as="span" variant="Secondary" radii="Pill">
- <Text size="T200">{email.address}</Text>
- </Chip>
- ))}
- </Box>
- {/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
- </SettingTile>
- </SequenceCard>
- </Box>
- );
-}
+import { MatrixId } from './MatrixId';
+import { Profile } from './Profile';
+import { ContactInformation } from './ContactInfo';
+import { IgnoredUserList } from './IgnoredUserList';
type AccountProps = {
requestClose: () => void;
<Profile />
<MatrixId />
<ContactInformation />
+ <IgnoredUserList />
</Box>
</PageContent>
</Scroll>
--- /dev/null
+import React, { useCallback, useEffect } from 'react';
+import { Box, Text, Chip } from 'folds';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+
+export function ContactInformation() {
+ const mx = useMatrixClient();
+ const [threePIdsState, loadThreePIds] = useAsyncCallback(
+ useCallback(() => mx.getThreePids(), [mx])
+ );
+ const threePIds =
+ threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
+
+ const emailIds = threePIds?.filter((id) => id.medium === 'email');
+
+ useEffect(() => {
+ loadThreePIds();
+ }, [loadThreePIds]);
+
+ return (
+ <Box direction="Column" gap="100">
+ <Text size="L400">Contact Information</Text>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile title="Email Address" description="Email address attached to your account.">
+ <Box>
+ {emailIds?.map((email) => (
+ <Chip key={email.address} as="span" variant="Secondary" radii="Pill">
+ <Text size="T200">{email.address}</Text>
+ </Chip>
+ ))}
+ </Box>
+ {/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
+ </SettingTile>
+ </SequenceCard>
+ </Box>
+ );
+}
--- /dev/null
+import React, { ChangeEventHandler, FormEventHandler, useCallback, useState } from 'react';
+import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { isUserId } from '../../../utils/matrix';
+import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
+import { useAlive } from '../../../hooks/useAlive';
+
+function IgnoreUserInput({ userList }: { userList: string[] }) {
+ const mx = useMatrixClient();
+ const [userId, setUserId] = useState<string>('');
+ const alive = useAlive();
+
+ const [ignoreState, ignore] = useAsyncCallback(
+ useCallback(
+ async (uId: string) => {
+ await mx.setIgnoredUsers([...userList, uId]);
+ },
+ [mx, userList]
+ )
+ );
+ const ignoring = ignoreState.status === AsyncStatus.Loading;
+
+ const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+ const uId = evt.currentTarget.value;
+ setUserId(uId);
+ };
+
+ const handleReset = () => {
+ setUserId('');
+ };
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (ignoring) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const userIdInput = target?.userIdInput as HTMLInputElement | undefined;
+ const uId = userIdInput?.value.trim();
+ if (!uId) return;
+
+ if (!isUserId(uId)) return;
+
+ ignore(uId).then(() => {
+ if (alive()) {
+ setUserId('');
+ }
+ });
+ };
+
+ return (
+ <Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={ignoring}>
+ <Box grow="Yes" direction="Column">
+ <Input
+ required
+ name="userIdInput"
+ value={userId}
+ onChange={handleChange}
+ variant="Secondary"
+ radii="300"
+ style={{ paddingRight: config.space.S200 }}
+ readOnly={ignoring}
+ after={
+ userId &&
+ !ignoring && (
+ <IconButton
+ type="reset"
+ onClick={handleReset}
+ size="300"
+ radii="300"
+ variant="Secondary"
+ >
+ <Icon src={Icons.Cross} size="100" />
+ </IconButton>
+ )
+ }
+ />
+ </Box>
+ <Button
+ size="400"
+ variant="Secondary"
+ fill="Soft"
+ outlined
+ radii="300"
+ type="submit"
+ disabled={ignoring}
+ >
+ {ignoring && <Spinner variant="Secondary" size="300" />}
+ <Text size="B400">Block</Text>
+ </Button>
+ </Box>
+ );
+}
+
+function IgnoredUserChip({ userId, userList }: { userId: string; userList: string[] }) {
+ const mx = useMatrixClient();
+ const [unignoreState, unignore] = useAsyncCallback(
+ useCallback(
+ () => mx.setIgnoredUsers(userList.filter((uId) => uId !== userId)),
+ [mx, userId, userList]
+ )
+ );
+
+ const handleUnignore = () => unignore();
+
+ const unIgnoring = unignoreState.status === AsyncStatus.Loading;
+ return (
+ <Chip
+ variant="Secondary"
+ radii="Pill"
+ after={
+ unIgnoring ? (
+ <Spinner variant="Secondary" size="100" />
+ ) : (
+ <Icon src={Icons.Cross} size="100" />
+ )
+ }
+ onClick={handleUnignore}
+ disabled={unIgnoring}
+ >
+ <Text size="T200" truncate>
+ {userId}
+ </Text>
+ </Chip>
+ );
+}
+
+export function IgnoredUserList() {
+ const ignoredUsers = useIgnoredUsers();
+
+ return (
+ <Box direction="Column" gap="100">
+ <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
+ <Text size="L400">Blocked Users</Text>
+ </Box>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Select User"
+ description="Prevent receiving messages or invites from user by adding their userId."
+ >
+ <Box direction="Column" gap="300">
+ <IgnoreUserInput userList={ignoredUsers} />
+ {ignoredUsers.length > 0 && (
+ <Box direction="Inherit" gap="100">
+ <Text size="L400">Users</Text>
+ <Box wrap="Wrap" gap="200">
+ {ignoredUsers.map((userId) => (
+ <IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
+ ))}
+ </Box>
+ </Box>
+ )}
+ </Box>
+ </SettingTile>
+ </SequenceCard>
+ </Box>
+ );
+}
--- /dev/null
+import React from 'react';
+import { Box, Text, Chip } from 'folds';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { copyToClipboard } from '../../../../util/common';
+
+export function MatrixId() {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+
+ return (
+ <Box direction="Column" gap="100">
+ <Text size="L400">Matrix ID</Text>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title={userId}
+ after={
+ <Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
+ <Text size="T200">Copy</Text>
+ </Chip>
+ }
+ />
+ </SequenceCard>
+ </Box>
+ );
+}
--- /dev/null
+import React, {
+ ChangeEventHandler,
+ FormEventHandler,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import {
+ Box,
+ Text,
+ IconButton,
+ Icon,
+ Icons,
+ Input,
+ Avatar,
+ Button,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Modal,
+ Dialog,
+ Header,
+ config,
+ Spinner,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
+import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
+import { UserAvatar } from '../../../components/user-avatar';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { nameInitials } from '../../../utils/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useFilePicker } from '../../../hooks/useFilePicker';
+import { useObjectURL } from '../../../hooks/useObjectURL';
+import { stopPropagation } from '../../../utils/keyboard';
+import { ImageEditor } from '../../../components/image-editor';
+import { ModalWide } from '../../../styles/Modal.css';
+import { createUploadAtom, UploadSuccess } from '../../../state/upload';
+import { CompactUploadCardRenderer } from '../../../components/upload-card';
+import { useCapabilities } from '../../../hooks/useCapabilities';
+
+type ProfileProps = {
+ profile: UserProfile;
+ userId: string;
+};
+function ProfileAvatar({ profile, userId }: ProfileProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const capabilities = useCapabilities();
+ const [alertRemove, setAlertRemove] = useState(false);
+ const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
+
+ const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
+ const avatarUrl = profile.avatarUrl
+ ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
+ : undefined;
+
+ const [imageFile, setImageFile] = useState<File>();
+ const imageFileURL = useObjectURL(imageFile);
+ const uploadAtom = useMemo(() => {
+ if (imageFile) return createUploadAtom(imageFile);
+ return undefined;
+ }, [imageFile]);
+
+ const pickFile = useFilePicker(setImageFile, false);
+
+ const handleRemoveUpload = useCallback(() => {
+ setImageFile(undefined);
+ }, []);
+
+ const handleUploaded = useCallback(
+ (upload: UploadSuccess) => {
+ const { mxc } = upload;
+ mx.setAvatarUrl(mxc);
+ handleRemoveUpload();
+ },
+ [mx, handleRemoveUpload]
+ );
+
+ const handleRemoveAvatar = () => {
+ mx.setAvatarUrl('');
+ setAlertRemove(false);
+ };
+
+ return (
+ <SettingTile
+ title={
+ <Text as="span" size="L400">
+ Avatar
+ </Text>
+ }
+ after={
+ <Avatar size="500" radii="300">
+ <UserAvatar
+ userId={userId}
+ src={avatarUrl}
+ renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
+ />
+ </Avatar>
+ }
+ >
+ {uploadAtom ? (
+ <Box gap="200" direction="Column">
+ <CompactUploadCardRenderer
+ uploadAtom={uploadAtom}
+ onRemove={handleRemoveUpload}
+ onComplete={handleUploaded}
+ />
+ </Box>
+ ) : (
+ <Box gap="200">
+ <Button
+ onClick={() => pickFile('image/*')}
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ outlined
+ radii="300"
+ disabled={disableSetAvatar}
+ >
+ <Text size="B300">Upload</Text>
+ </Button>
+ {avatarUrl && (
+ <Button
+ size="300"
+ variant="Critical"
+ fill="None"
+ radii="300"
+ disabled={disableSetAvatar}
+ onClick={() => setAlertRemove(true)}
+ >
+ <Text size="B300">Remove</Text>
+ </Button>
+ )}
+ </Box>
+ )}
+
+ {imageFileURL && (
+ <Overlay open={false} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleRemoveUpload,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Modal className={ModalWide} variant="Surface" size="500">
+ <ImageEditor
+ name={imageFile?.name ?? 'Unnamed'}
+ url={imageFileURL}
+ requestClose={handleRemoveUpload}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+
+ <Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setAlertRemove(false),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Remove Avatar</Text>
+ </Box>
+ <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+ <Box direction="Column" gap="200">
+ <Text priority="400">Are you sure you want to remove profile avatar?</Text>
+ </Box>
+ <Button variant="Critical" onClick={handleRemoveAvatar}>
+ <Text size="B400">Remove</Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ </SettingTile>
+ );
+}
+
+function ProfileDisplayName({ profile, userId }: ProfileProps) {
+ const mx = useMatrixClient();
+ const capabilities = useCapabilities();
+ const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
+
+ const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
+ const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
+
+ const [changeState, changeDisplayName] = useAsyncCallback(
+ useCallback((name: string) => mx.setDisplayName(name), [mx])
+ );
+ const changingDisplayName = changeState.status === AsyncStatus.Loading;
+
+ useEffect(() => {
+ setDisplayName(defaultDisplayName);
+ }, [defaultDisplayName]);
+
+ const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+ const name = evt.currentTarget.value;
+ setDisplayName(name);
+ };
+
+ const handleReset = () => {
+ setDisplayName(defaultDisplayName);
+ };
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (changingDisplayName) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
+ const name = displayNameInput?.value;
+ if (!name) return;
+
+ changeDisplayName(name);
+ };
+
+ const hasChanges = displayName !== defaultDisplayName;
+ return (
+ <SettingTile
+ title={
+ <Text as="span" size="L400">
+ Display Name
+ </Text>
+ }
+ >
+ <Box direction="Column" grow="Yes" gap="100">
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ gap="200"
+ aria-disabled={changingDisplayName || disableSetDisplayname}
+ >
+ <Box grow="Yes" direction="Column">
+ <Input
+ required
+ name="displayNameInput"
+ value={displayName}
+ onChange={handleChange}
+ variant="Secondary"
+ radii="300"
+ style={{ paddingRight: config.space.S200 }}
+ readOnly={changingDisplayName || disableSetDisplayname}
+ after={
+ hasChanges &&
+ !changingDisplayName && (
+ <IconButton
+ type="reset"
+ onClick={handleReset}
+ size="300"
+ radii="300"
+ variant="Secondary"
+ >
+ <Icon src={Icons.Cross} size="100" />
+ </IconButton>
+ )
+ }
+ />
+ </Box>
+ <Button
+ size="400"
+ variant={hasChanges ? 'Success' : 'Secondary'}
+ fill={hasChanges ? 'Solid' : 'Soft'}
+ outlined
+ radii="300"
+ disabled={!hasChanges || changingDisplayName}
+ type="submit"
+ >
+ {changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
+ <Text size="B400">Save</Text>
+ </Button>
+ </Box>
+ </Box>
+ </SettingTile>
+ );
+}
+
+export function Profile() {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+ const profile = useUserProfile(userId);
+
+ return (
+ <Box direction="Column" gap="100">
+ <Text size="L400">Profile</Text>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <ProfileAvatar userId={userId} profile={profile} />
+ <ProfileDisplayName userId={userId} profile={profile} />
+ </SequenceCard>
+ </Box>
+ );
+}
+++ /dev/null
-import React, { ChangeEventHandler, FormEventHandler, useCallback, useState } from 'react';
-import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds';
-import { SequenceCard } from '../../../components/sequence-card';
-import { SequenceCardStyle } from '../styles.css';
-import { SettingTile } from '../../../components/setting-tile';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { isUserId } from '../../../utils/matrix';
-import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
-
-function IgnoreUserInput({ userList }: { userList: string[] }) {
- const mx = useMatrixClient();
- const [userId, setUserId] = useState<string>('');
-
- const [ignoreState, ignore] = useAsyncCallback(
- useCallback(
- async (uId: string) => {
- mx.setIgnoredUsers([...userList, uId]);
- setUserId('');
- },
- [mx, userList]
- )
- );
- const ignoring = ignoreState.status === AsyncStatus.Loading;
-
- const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
- const uId = evt.currentTarget.value;
- setUserId(uId);
- };
-
- const handleReset = () => {
- setUserId('');
- };
-
- const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
- evt.preventDefault();
- if (ignoring) return;
-
- const target = evt.target as HTMLFormElement | undefined;
- const userIdInput = target?.userIdInput as HTMLInputElement | undefined;
- const uId = userIdInput?.value.trim();
- if (!uId) return;
-
- if (!isUserId(uId)) return;
-
- ignore(uId);
- };
-
- return (
- <Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={ignoring}>
- <Box grow="Yes" direction="Column">
- <Input
- required
- name="userIdInput"
- value={userId}
- onChange={handleChange}
- variant="Secondary"
- radii="300"
- style={{ paddingRight: config.space.S200 }}
- readOnly={ignoring}
- after={
- userId &&
- !ignoring && (
- <IconButton
- type="reset"
- onClick={handleReset}
- size="300"
- radii="300"
- variant="Secondary"
- >
- <Icon src={Icons.Cross} size="100" />
- </IconButton>
- )
- }
- />
- </Box>
- <Button
- size="400"
- variant="Secondary"
- fill="Soft"
- outlined
- radii="300"
- type="submit"
- disabled={ignoring}
- >
- {ignoring && <Spinner variant="Secondary" size="300" />}
- <Text size="B400">Block</Text>
- </Button>
- </Box>
- );
-}
-
-function IgnoredUserChip({ userId, userList }: { userId: string; userList: string[] }) {
- const mx = useMatrixClient();
- const [unignoreState, unignore] = useAsyncCallback(
- useCallback(
- () => mx.setIgnoredUsers(userList.filter((uId) => uId !== userId)),
- [mx, userId, userList]
- )
- );
-
- const handleUnignore = () => unignore();
-
- const unIgnoring = unignoreState.status === AsyncStatus.Loading;
- return (
- <Chip
- variant="Secondary"
- radii="Pill"
- after={
- unIgnoring ? (
- <Spinner variant="Secondary" size="100" />
- ) : (
- <Icon src={Icons.Cross} size="100" />
- )
- }
- onClick={handleUnignore}
- disabled={unIgnoring}
- >
- <Text size="T200" truncate>
- {userId}
- </Text>
- </Chip>
- );
-}
-
-export function IgnoredUserList() {
- const ignoredUsers = useIgnoredUsers();
-
- return (
- <Box direction="Column" gap="100">
- <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
- <Text size="L400">Block Messages</Text>
- </Box>
- <SequenceCard
- className={SequenceCardStyle}
- variant="SurfaceVariant"
- direction="Column"
- gap="400"
- >
- <SettingTile
- title="Select User"
- description="Prevent receiving message by adding userId into blocklist."
- >
- <Box direction="Column" gap="300">
- <IgnoreUserInput userList={ignoredUsers} />
- {ignoredUsers.length > 0 && (
- <Box direction="Inherit" gap="100">
- <Text size="L400">Blocklist</Text>
- <Box wrap="Wrap" gap="200">
- {ignoredUsers.map((userId) => (
- <IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
- ))}
- </Box>
- </Box>
- )}
- </Box>
- </SettingTile>
- </SequenceCard>
- </Box>
- );
-}
import { AllMessagesNotifications } from './AllMessages';
import { SpecialMessagesNotifications } from './SpecialMessages';
import { KeywordMessagesNotifications } from './KeywordMessages';
-import { IgnoredUserList } from './IgnoredUserList';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
type NotificationsProps = {
requestClose: () => void;
<AllMessagesNotifications />
<SpecialMessagesNotifications />
<KeywordMessagesNotifications />
- <IgnoredUserList />
+ <Box direction="Column" gap="100">
+ <Text size="L400">Block Messages</Text>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ description={'This option has been moved to "Account > Block Users" section.'}
+ />
+ </SequenceCard>
+ </Box>
</Box>
</PageContent>
</Scroll>
--- /dev/null
+import { useSpecVersions } from './useSpecVersions';
+
+export const useReportRoomSupported = (): boolean => {
+ const { versions, unstable_features: unstableFeatures } = useSpecVersions();
+
+ // report room is introduced in spec version 1.13
+ const supported = unstableFeatures?.['org.matrix.msc4151'] || versions.includes('v1.13');
+
+ return supported;
+};
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
- Invitations
+ Invites
</Text>
</Box>
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
-import React, { useCallback, useRef, useState } from 'react';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Avatar,
+ Badge,
Box,
Button,
+ Chip,
Icon,
IconButton,
Icons,
config,
} from 'folds';
import { useAtomValue } from 'jotai';
+import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
-import { MatrixError, Room } from 'matrix-js-sdk';
-import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
-import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList';
+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 { mDirectAtom } from '../../../state/mDirectList';
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 } from '../../../utils/matrix';
+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 { useRoomTopic } from '../../../hooks/useRoomMeta';
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 InviteCardProps = {
+type InviteData = {
room: Room;
- userId: string;
- direct?: boolean;
- compact?: boolean;
- onNavigate: (roomId: string) => void;
+ roomId: string;
+ roomName: string;
+ roomAvatar?: string;
+ roomTopic?: string;
+ roomAlias?: string;
+
+ senderId: string;
+ senderName: string;
+ inviteTs?: number;
+
+ isSpace: boolean;
+ isDirect: boolean;
+ isEncrypted: boolean;
};
-function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
+
+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<RoomTopicEventContent>()?.topic ??
+ undefined;
+
const member = room.getMember(userId);
const memberEvent = member?.events.member;
- const memberTs = memberEvent?.getTs() ?? 0;
+
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,
- const topic = useRoomTopic(room);
+ 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 [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
- const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined;
+ const dmUserId = isDirectInvite(invite.room, userId)
+ ? guessDmRoomUserId(invite.room, userId)
+ : undefined;
- await mx.joinRoom(room.roomId);
+ await mx.joinRoom(invite.roomId);
if (dmUserId) {
- await addRoomIdToMDirect(mx, room.roomId, dmUserId);
+ await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
}
- onNavigate(room.roomId);
- }, [mx, room, userId, onNavigate])
+ onNavigate(invite.roomId, invite.isSpace);
+ }, [mx, invite, userId, onNavigate])
);
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
- useCallback(() => mx.leave(room.roomId), [mx, room])
+ useCallback(() => mx.leave(invite.roomId), [mx, invite])
);
const joining =
<SequenceCard
variant="SurfaceVariant"
direction="Column"
- gap="200"
- style={{ padding: config.space.S400, paddingTop: config.space.S200 }}
+ gap="300"
+ style={{ padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}` }}
>
- <Box gap="200" alignItems="Baseline">
- <Box grow="Yes">
- <Text size="T200" priority="300" truncate>
- Invited by <b>{senderName}</b>
- </Text>
- </Box>
- <Box shrink="No">
- <Time size="T200" ts={memberTs} priority="300" />
+ {(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>
- </Box>
+ )}
<Box gap="300">
<Avatar size="300">
<RoomAvatar
- roomId={room.roomId}
- src={direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)}
- alt={roomName}
+ roomId={invite.roomId}
+ src={hideAvatar ? undefined : invite.roomAvatar}
+ alt={invite.roomName}
renderFallback={() => (
<Text as="span" size="H6">
- {nameInitials(roomName)}
+ {nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
</Text>
)}
/>
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column">
<Text size="T300" truncate>
- <b>{roomName}</b>
+ <b>{invite.roomName}</b>
</Text>
- {topic && (
+ {invite.roomTopic && (
<Text
size="T200"
onClick={openTopic}
tabIndex={0}
truncate
>
- {topic}
+ {invite.roomTopic}
</Text>
)}
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
}}
>
<RoomTopicViewer
- name={roomName}
- topic={topic ?? ''}
+ name={invite.roomName}
+ topic={invite.roomTopic ?? ''}
requestClose={closeTopic}
/>
</FocusTrap>
onClick={leave}
size="300"
variant="Secondary"
+ radii="300"
fill="Soft"
disabled={joining || leaving}
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
<Button
onClick={join}
size="300"
- variant="Primary"
+ variant="Success"
fill="Soft"
+ radii="300"
outlined
disabled={joining || leaving}
- before={joining ? <Spinner variant="Primary" fill="Soft" size="100" /> : undefined}
+ before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
>
<Text size="B300">Accept</Text>
</Button>
</Box>
</Box>
</Box>
+ <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>
);
}
+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, it’ll 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>
+ <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>
+ </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();
- const userId = mx.getUserId()!;
- const mDirects = useAtomValue(mDirectAtom);
- const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects);
- const spaceInvites = useSpaceInvites(mx, allInvitesAtom);
- const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
+ 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(
);
const screenSize = useScreenSizeContext();
- const { navigateRoom, navigateSpace } = useRoomNavigate();
-
- const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => {
- const room = mx.getRoom(roomId);
- if (!room) return null;
- return (
- <InviteCard
- key={roomId}
- room={room}
- userId={userId}
- compact={compact}
- direct={direct}
- onNavigate={handleNavigate}
- />
- );
+ const handleNavigate = (roomId: string, space: boolean) => {
+ if (space) {
+ navigateSpace(roomId);
+ return;
+ }
+ navigateRoom(roomId);
};
return (
<Box alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
<Text size="H3" truncate>
- Invitations
+ Invites
</Text>
</Box>
<Box grow="Yes" basis="No" />
<PageContent>
<PageContentCenter>
<Box ref={containerRef} direction="Column" gap="600">
- {directInvites.length > 0 && (
- <Box direction="Column" gap="200">
- <Text size="H4">Direct Messages</Text>
- <Box direction="Column" gap="100">
- {directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))}
- </Box>
- </Box>
+ <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}
+ />
)}
- {spaceInvites.length > 0 && (
- <Box direction="Column" gap="200">
- <Text size="H4">Spaces</Text>
- <Box direction="Column" gap="100">
- {spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))}
- </Box>
- </Box>
+
+ {filter === InviteFilter.Unknown && (
+ <UnknownInvites
+ invites={unknownInvites}
+ compact={compact}
+ handleNavigate={handleNavigate}
+ />
)}
- {roomInvites.length > 0 && (
- <Box direction="Column" gap="200">
- <Text size="H4">Rooms</Text>
- <Box direction="Column" gap="100">
- {roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))}
- </Box>
- </Box>
+
+ {filter === InviteFilter.Spam && (
+ <SpamInvites
+ invites={spamInvites}
+ compact={compact}
+ handleNavigate={handleNavigate}
+ />
)}
- {directInvites.length === 0 &&
- spaceInvites.length === 0 &&
- roomInvites.length === 0 && (
- <div>
- <SequenceCard
- variant="SurfaceVariant"
- style={{ padding: config.space.S400 }}
- direction="Column"
- gap="200"
- >
- <Text>No Pending Invitations</Text>
- <Text size="T200">
- You don't have any new pending invitations to display yet.
- </Text>
- </SequenceCard>
- </div>
- )}
</Box>
</PageContentCenter>
</PageContent>
--- /dev/null
+import * as badWords from 'badwords-list';
+import { sanitizeForRegex } from '../utils/regex';
+
+const additionalBadWords: string[] = ['Torture', 'T0rture'];
+
+const fullBadWordList = additionalBadWords.concat(
+ badWords.array.filter((word) => !additionalBadWords.includes(word))
+);
+
+export const BAD_WORDS_REGEX = new RegExp(
+ `(\\b|_)(${fullBadWordList.map((word) => sanitizeForRegex(word)).join('|')})(\\b|_)`,
+ 'g'
+);
+
+export const testBadWords = (str: string): boolean => !!str.toLowerCase().match(BAD_WORDS_REGEX);
maxRetryCount?: number
) => {
let retryCount = 0;
+
+ let actionInterval = 0;
+
+ const sleepForMs = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
const performAction = async (dataItem: T) => {
const [err] = await to<R, MatrixError>(callback(dataItem));
return;
}
- const waitMS = err.getRetryAfterMs() ?? 200;
- await new Promise((resolve) => {
- setTimeout(resolve, waitMS);
- });
+ const waitMS = err.getRetryAfterMs() ?? 3000;
+ actionInterval = waitMS + 500;
+ await sleepForMs(waitMS);
retryCount += 1;
await performAction(dataItem);
retryCount = 0;
// eslint-disable-next-line no-await-in-loop
await performAction(dataItem);
+ if (actionInterval > 0) {
+ // eslint-disable-next-line no-await-in-loop
+ await sleepForMs(actionInterval);
+ }
}
};
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
+ Membership,
MessageEvent,
NotificationType,
RoomToParents,
}
if (!roomPushRule) {
- const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
+ const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>()
?.global?.override;
if (!overrideRules) return NotificationType.Default;
return mMentions;
};
+
+export const getCommonRooms = (
+ mx: MatrixClient,
+ rooms: string[],
+ otherUserId: string
+): string[] => {
+ const commonRooms: string[] = [];
+
+ rooms.forEach((roomId) => {
+ const room = mx.getRoom(roomId);
+ if (!room || room.getMyMembership() !== Membership.Join) return;
+
+ const common = room.hasMembershipState(otherUserId, Membership.Join);
+ if (common) {
+ commonRooms.push(roomId);
+ }
+ });
+
+ return commonRooms;
+};
+
+export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: string): boolean =>
+ rooms.some((roomId) => {
+ const room = mx.getRoom(roomId);
+ if (!room || room.getMyMembership() !== Membership.Join) return false;
+
+ const banned = room.hasMembershipState(otherUserId, Membership.Ban);
+ return banned;
+ });