Better invites management (#2336)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sat, 24 May 2025 14:37:56 +0000 (20:07 +0530)
committerGitHub <noreply@github.com>
Sat, 24 May 2025 14:37:56 +0000 (20:07 +0530)
* move block users to account settings

* filter invites and add more options

* add better rate limit recovery in rateLimitedActions util function

18 files changed:
package-lock.json
package.json
src/app/components/page/Page.tsx
src/app/components/page/style.css.ts
src/app/features/message-search/MessageSearch.tsx
src/app/features/settings/account/Account.tsx
src/app/features/settings/account/ContactInfo.tsx [new file with mode: 0644]
src/app/features/settings/account/IgnoredUserList.tsx [new file with mode: 0644]
src/app/features/settings/account/MatrixId.tsx [new file with mode: 0644]
src/app/features/settings/account/Profile.tsx [new file with mode: 0644]
src/app/features/settings/notifications/IgnoredUserList.tsx [deleted file]
src/app/features/settings/notifications/Notifications.tsx
src/app/hooks/useReportRoomSupported.ts [new file with mode: 0644]
src/app/pages/client/inbox/Inbox.tsx
src/app/pages/client/inbox/Invites.tsx
src/app/plugins/bad-words.ts [new file with mode: 0644]
src/app/utils/matrix.ts
src/app/utils/room.ts

index 5fd5b68680beb8ee79097e6c59594c7125f1ea72..e553add9773b349271e9034b4f2bfc75be68472f 100644 (file)
@@ -21,6 +21,7 @@
         "@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",
index 983cbe4ffd8f922fc017ea42d29348f6604c5d88..01ba9647eb7ffd1fac778ef30780bbdb0d5a0407 100644 (file)
@@ -32,6 +32,7 @@
     "@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",
index 55cceceecc6fde22b9a73e1ed2c7dfc3a0f56154..a54563855cdf0d9c4df1948de247af5a5e5c4930 100644 (file)
@@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
   <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
index 0abd4dfa9150cc968c897ef8f3093bf0cecf364e..bd14cd5839c1ff289e4d90c77342ffab3299b9ea 100644 (file)
@@ -92,6 +92,15 @@ export const PageContent = style([
   },
 ]);
 
+export const PageHeroEmpty = style([
+  DefaultReset,
+  {
+    padding: config.space.S400,
+    borderRadius: config.radii.R400,
+    minHeight: toRem(450),
+  },
+]);
+
 export const PageHeroSection = style([
   DefaultReset,
   {
index 8eae4aeaac394cb7f031a6254e9b188b45622ca0..415aa2456b5a5359dc12b1ac4786c695c6fa1e13 100644 (file)
@@ -5,7 +5,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
 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';
@@ -222,18 +222,7 @@ export function MessageSearch({
       </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} />}
@@ -241,7 +230,7 @@ export function MessageSearch({
               subTitle="Find helpful messages in your community by searching with related keywords."
             />
           </PageHeroSection>
-        </Box>
+        </PageHeroEmpty>
       )}
 
       {msgSearchParams.term && groups.length === 0 && status === 'success' && (
index bfdb0ef596ee95654ed5b734ad4444ec394cf256..c4b56e47547d71c4fdd3a497009368a770a931c1 100644 (file)
-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;
@@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
               <Profile />
               <MatrixId />
               <ContactInformation />
+              <IgnoredUserList />
             </Box>
           </PageContent>
         </Scroll>
diff --git a/src/app/features/settings/account/ContactInfo.tsx b/src/app/features/settings/account/ContactInfo.tsx
new file mode 100644 (file)
index 0000000..cfde7e2
--- /dev/null
@@ -0,0 +1,45 @@
+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>
+  );
+}
diff --git a/src/app/features/settings/account/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx
new file mode 100644 (file)
index 0000000..98db945
--- /dev/null
@@ -0,0 +1,166 @@
+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>
+  );
+}
diff --git a/src/app/features/settings/account/MatrixId.tsx b/src/app/features/settings/account/MatrixId.tsx
new file mode 100644 (file)
index 0000000..ac9b1fb
--- /dev/null
@@ -0,0 +1,33 @@
+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>
+  );
+}
diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
new file mode 100644 (file)
index 0000000..e982a79
--- /dev/null
@@ -0,0 +1,325 @@
+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>
+  );
+}
diff --git a/src/app/features/settings/notifications/IgnoredUserList.tsx b/src/app/features/settings/notifications/IgnoredUserList.tsx
deleted file mode 100644 (file)
index 0ff3015..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-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>
-  );
-}
index aa339a03116cd572df34c21b8bffa7c89edc5226..095a9bba91aea33f5082a0641aaf1b99369f37e0 100644 (file)
@@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification';
 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;
@@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) {
               <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>
diff --git a/src/app/hooks/useReportRoomSupported.ts b/src/app/hooks/useReportRoomSupported.ts
new file mode 100644 (file)
index 0000000..198172c
--- /dev/null
@@ -0,0 +1,10 @@
+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;
+};
index 686296b76f7f9c9218fcca94bd66a63d6c902fc5..67d6021a3e33ca776e01ce4c09dd1bde8bcc440e 100644 (file)
@@ -32,7 +32,7 @@ function InvitesNavItem() {
             </Avatar>
             <Box as="span" grow="Yes">
               <Text as="span" size="Inherit" truncate>
-                Invitations
+                Invites
               </Text>
             </Box>
             {inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
index 8dcfa1c2093e0f46a8ccd30b15725e1834c01972..63fd21e11c213355f22f391eb05a747c161d40cf 100644 (file)
@@ -1,8 +1,10 @@
-import React, { useCallback, useRef, useState } from 'react';
+import React, { useCallback, useMemo, useRef, useState } from 'react';
 import {
   Avatar,
+  Badge,
   Box,
   Button,
+  Chip,
   Icon,
   IconButton,
   Icons,
@@ -16,56 +18,129 @@ import {
   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);
@@ -73,17 +148,19 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
 
   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 =
@@ -95,28 +172,43 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
     <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>
             )}
           />
@@ -125,9 +217,9 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
           <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}
@@ -135,7 +227,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
                   tabIndex={0}
                   truncate
                 >
-                  {topic}
+                  {invite.roomTopic}
                 </Text>
               )}
               <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
@@ -149,8 +241,8 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
                     }}
                   >
                     <RoomTopicViewer
-                      name={roomName}
-                      topic={topic ?? ''}
+                      name={invite.roomName}
+                      topic={invite.roomTopic ?? ''}
                       requestClose={closeTopic}
                     />
                   </FocusTrap>
@@ -173,6 +265,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
               onClick={leave}
               size="300"
               variant="Secondary"
+              radii="300"
               fill="Soft"
               disabled={joining || leaving}
               before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
@@ -182,28 +275,392 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
             <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(
@@ -212,21 +669,12 @@ export function Invites() {
   );
   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 (
@@ -247,7 +695,7 @@ export function Invites() {
           <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" />
@@ -258,47 +706,40 @@ export function Invites() {
           <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&apos;t have any new pending invitations to display yet.
-                        </Text>
-                      </SequenceCard>
-                    </div>
-                  )}
               </Box>
             </PageContentCenter>
           </PageContent>
diff --git a/src/app/plugins/bad-words.ts b/src/app/plugins/bad-words.ts
new file mode 100644 (file)
index 0000000..a7ca468
--- /dev/null
@@ -0,0 +1,15 @@
+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);
index 75430c2051a3c99cea36a66da481624c65bc1764..810f720975b3a0bc0cf101cc25dccb13442e69fe 100644 (file)
@@ -304,6 +304,14 @@ export const rateLimitedActions = async <T, R = void>(
   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));
 
@@ -312,10 +320,9 @@ export const rateLimitedActions = async <T, R = void>(
         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);
@@ -327,5 +334,9 @@ export const rateLimitedActions = async <T, R = void>(
     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);
+    }
   }
 };
index 3bf8cd5a4bd61596ab5ce921cf59ad7d6f02920f..79dcff9ec04f7032ce304b789f627d6a69b61f71 100644 (file)
@@ -19,6 +19,7 @@ import {
 import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
 import { AccountDataEvent } from '../../types/matrix/accountData';
 import {
+  Membership,
   MessageEvent,
   NotificationType,
   RoomToParents,
@@ -171,7 +172,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
   }
 
   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;
 
@@ -443,3 +444,32 @@ export const getMentionContent = (userIds: string[], room: boolean): IMentions =
 
   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;
+  });