mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	Better invites management (#2336)
* move block users to account settings * filter invites and add more options * add better rate limit recovery in rateLimitedActions util function
This commit is contained in:
		
							parent
							
								
									0d27bde33e
								
							
						
					
					
						commit
						206ed33516
					
				
					 17 changed files with 1088 additions and 524 deletions
				
			
		
							
								
								
									
										7
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
					@ -21,6 +21,7 @@
 | 
				
			||||||
        "@vanilla-extract/recipes": "0.3.0",
 | 
					        "@vanilla-extract/recipes": "0.3.0",
 | 
				
			||||||
        "@vanilla-extract/vite-plugin": "3.7.1",
 | 
					        "@vanilla-extract/vite-plugin": "3.7.1",
 | 
				
			||||||
        "await-to-js": "3.0.0",
 | 
					        "await-to-js": "3.0.0",
 | 
				
			||||||
 | 
					        "badwords-list": "2.0.1-4",
 | 
				
			||||||
        "blurhash": "2.0.4",
 | 
					        "blurhash": "2.0.4",
 | 
				
			||||||
        "browser-encrypt-attachment": "0.3.0",
 | 
					        "browser-encrypt-attachment": "0.3.0",
 | 
				
			||||||
        "chroma-js": "3.1.2",
 | 
					        "chroma-js": "3.1.2",
 | 
				
			||||||
| 
						 | 
					@ -5436,6 +5437,12 @@
 | 
				
			||||||
        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
 | 
					        "@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": {
 | 
					    "node_modules/balanced-match": {
 | 
				
			||||||
      "version": "1.0.2",
 | 
					      "version": "1.0.2",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -32,6 +32,7 @@
 | 
				
			||||||
    "@vanilla-extract/recipes": "0.3.0",
 | 
					    "@vanilla-extract/recipes": "0.3.0",
 | 
				
			||||||
    "@vanilla-extract/vite-plugin": "3.7.1",
 | 
					    "@vanilla-extract/vite-plugin": "3.7.1",
 | 
				
			||||||
    "await-to-js": "3.0.0",
 | 
					    "await-to-js": "3.0.0",
 | 
				
			||||||
 | 
					    "badwords-list": "2.0.1-4",
 | 
				
			||||||
    "blurhash": "2.0.4",
 | 
					    "blurhash": "2.0.4",
 | 
				
			||||||
    "browser-encrypt-attachment": "0.3.0",
 | 
					    "browser-encrypt-attachment": "0.3.0",
 | 
				
			||||||
    "chroma-js": "3.1.2",
 | 
					    "chroma-js": "3.1.2",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
 | 
				
			||||||
  <div className={classNames(css.PageContent, className)} {...props} ref={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>>(
 | 
					export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
 | 
				
			||||||
  ({ className, ...props }, ref) => (
 | 
					  ({ className, ...props }, ref) => (
 | 
				
			||||||
    <Box
 | 
					    <Box
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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([
 | 
					export const PageHeroSection = style([
 | 
				
			||||||
  DefaultReset,
 | 
					  DefaultReset,
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,7 +5,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
 | 
				
			||||||
import { useInfiniteQuery } from '@tanstack/react-query';
 | 
					import { useInfiniteQuery } from '@tanstack/react-query';
 | 
				
			||||||
import { useSearchParams } from 'react-router-dom';
 | 
					import { useSearchParams } from 'react-router-dom';
 | 
				
			||||||
import { SearchOrderBy } from 'matrix-js-sdk';
 | 
					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 { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
				
			||||||
import { _SearchPathSearchParams } from '../../pages/paths';
 | 
					import { _SearchPathSearchParams } from '../../pages/paths';
 | 
				
			||||||
import { useSetting } from '../../state/hooks/settings';
 | 
					import { useSetting } from '../../state/hooks/settings';
 | 
				
			||||||
| 
						 | 
					@ -222,18 +222,7 @@ export function MessageSearch({
 | 
				
			||||||
      </Box>
 | 
					      </Box>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {!msgSearchParams.term && status === 'pending' && (
 | 
					      {!msgSearchParams.term && status === 'pending' && (
 | 
				
			||||||
        <Box
 | 
					        <PageHeroEmpty>
 | 
				
			||||||
          className={ContainerColor({ variant: 'SurfaceVariant' })}
 | 
					 | 
				
			||||||
          style={{
 | 
					 | 
				
			||||||
            padding: config.space.S400,
 | 
					 | 
				
			||||||
            borderRadius: config.radii.R400,
 | 
					 | 
				
			||||||
            minHeight: toRem(450),
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
          direction="Column"
 | 
					 | 
				
			||||||
          alignItems="Center"
 | 
					 | 
				
			||||||
          justifyContent="Center"
 | 
					 | 
				
			||||||
          gap="200"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <PageHeroSection>
 | 
					          <PageHeroSection>
 | 
				
			||||||
            <PageHero
 | 
					            <PageHero
 | 
				
			||||||
              icon={<Icon size="600" src={Icons.Message} />}
 | 
					              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."
 | 
					              subTitle="Find helpful messages in your community by searching with related keywords."
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </PageHeroSection>
 | 
					          </PageHeroSection>
 | 
				
			||||||
        </Box>
 | 
					        </PageHeroEmpty>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {msgSearchParams.term && groups.length === 0 && status === 'success' && (
 | 
					      {msgSearchParams.term && groups.length === 0 && status === 'success' && (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,396 +1,10 @@
 | 
				
			||||||
import React, {
 | 
					import React from 'react';
 | 
				
			||||||
  ChangeEventHandler,
 | 
					import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
 | 
				
			||||||
  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 { Page, PageContent, PageHeader } from '../../../components/page';
 | 
					import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
				
			||||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
					import { MatrixId } from './MatrixId';
 | 
				
			||||||
import { SequenceCardStyle } from '../styles.css';
 | 
					import { Profile } from './Profile';
 | 
				
			||||||
import { SettingTile } from '../../../components/setting-tile';
 | 
					import { ContactInformation } from './ContactInfo';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { IgnoredUserList } from './IgnoredUserList';
 | 
				
			||||||
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>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
type AccountProps = {
 | 
					type AccountProps = {
 | 
				
			||||||
  requestClose: () => void;
 | 
					  requestClose: () => void;
 | 
				
			||||||
| 
						 | 
					@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
 | 
				
			||||||
              <Profile />
 | 
					              <Profile />
 | 
				
			||||||
              <MatrixId />
 | 
					              <MatrixId />
 | 
				
			||||||
              <ContactInformation />
 | 
					              <ContactInformation />
 | 
				
			||||||
 | 
					              <IgnoredUserList />
 | 
				
			||||||
            </Box>
 | 
					            </Box>
 | 
				
			||||||
          </PageContent>
 | 
					          </PageContent>
 | 
				
			||||||
        </Scroll>
 | 
					        </Scroll>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										45
									
								
								src/app/features/settings/account/ContactInfo.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/app/features/settings/account/ContactInfo.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -7,16 +7,17 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
				
			||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
					import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
				
			||||||
import { isUserId } from '../../../utils/matrix';
 | 
					import { isUserId } from '../../../utils/matrix';
 | 
				
			||||||
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
 | 
					import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
 | 
				
			||||||
 | 
					import { useAlive } from '../../../hooks/useAlive';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function IgnoreUserInput({ userList }: { userList: string[] }) {
 | 
					function IgnoreUserInput({ userList }: { userList: string[] }) {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const [userId, setUserId] = useState<string>('');
 | 
					  const [userId, setUserId] = useState<string>('');
 | 
				
			||||||
 | 
					  const alive = useAlive();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [ignoreState, ignore] = useAsyncCallback(
 | 
					  const [ignoreState, ignore] = useAsyncCallback(
 | 
				
			||||||
    useCallback(
 | 
					    useCallback(
 | 
				
			||||||
      async (uId: string) => {
 | 
					      async (uId: string) => {
 | 
				
			||||||
        mx.setIgnoredUsers([...userList, uId]);
 | 
					        await mx.setIgnoredUsers([...userList, uId]);
 | 
				
			||||||
        setUserId('');
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      [mx, userList]
 | 
					      [mx, userList]
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
| 
						 | 
					@ -43,7 +44,11 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!isUserId(uId)) return;
 | 
					    if (!isUserId(uId)) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ignore(uId);
 | 
					    ignore(uId).then(() => {
 | 
				
			||||||
 | 
					      if (alive()) {
 | 
				
			||||||
 | 
					        setUserId('');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					@ -129,7 +134,7 @@ export function IgnoredUserList() {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Box direction="Column" gap="100">
 | 
					    <Box direction="Column" gap="100">
 | 
				
			||||||
      <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
 | 
					      <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
 | 
				
			||||||
        <Text size="L400">Block Messages</Text>
 | 
					        <Text size="L400">Blocked Users</Text>
 | 
				
			||||||
      </Box>
 | 
					      </Box>
 | 
				
			||||||
      <SequenceCard
 | 
					      <SequenceCard
 | 
				
			||||||
        className={SequenceCardStyle}
 | 
					        className={SequenceCardStyle}
 | 
				
			||||||
| 
						 | 
					@ -139,13 +144,13 @@ export function IgnoredUserList() {
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <SettingTile
 | 
					        <SettingTile
 | 
				
			||||||
          title="Select User"
 | 
					          title="Select User"
 | 
				
			||||||
          description="Prevent receiving message by adding userId into blocklist."
 | 
					          description="Prevent receiving messages or invites from user by adding their userId."
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Box direction="Column" gap="300">
 | 
					          <Box direction="Column" gap="300">
 | 
				
			||||||
            <IgnoreUserInput userList={ignoredUsers} />
 | 
					            <IgnoreUserInput userList={ignoredUsers} />
 | 
				
			||||||
            {ignoredUsers.length > 0 && (
 | 
					            {ignoredUsers.length > 0 && (
 | 
				
			||||||
              <Box direction="Inherit" gap="100">
 | 
					              <Box direction="Inherit" gap="100">
 | 
				
			||||||
                <Text size="L400">Blocklist</Text>
 | 
					                <Text size="L400">Users</Text>
 | 
				
			||||||
                <Box wrap="Wrap" gap="200">
 | 
					                <Box wrap="Wrap" gap="200">
 | 
				
			||||||
                  {ignoredUsers.map((userId) => (
 | 
					                  {ignoredUsers.map((userId) => (
 | 
				
			||||||
                    <IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
 | 
					                    <IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
 | 
				
			||||||
							
								
								
									
										33
									
								
								src/app/features/settings/account/MatrixId.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/app/features/settings/account/MatrixId.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										325
									
								
								src/app/features/settings/account/Profile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								src/app/features/settings/account/Profile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification';
 | 
				
			||||||
import { AllMessagesNotifications } from './AllMessages';
 | 
					import { AllMessagesNotifications } from './AllMessages';
 | 
				
			||||||
import { SpecialMessagesNotifications } from './SpecialMessages';
 | 
					import { SpecialMessagesNotifications } from './SpecialMessages';
 | 
				
			||||||
import { KeywordMessagesNotifications } from './KeywordMessages';
 | 
					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 = {
 | 
					type NotificationsProps = {
 | 
				
			||||||
  requestClose: () => void;
 | 
					  requestClose: () => void;
 | 
				
			||||||
| 
						 | 
					@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) {
 | 
				
			||||||
              <AllMessagesNotifications />
 | 
					              <AllMessagesNotifications />
 | 
				
			||||||
              <SpecialMessagesNotifications />
 | 
					              <SpecialMessagesNotifications />
 | 
				
			||||||
              <KeywordMessagesNotifications />
 | 
					              <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>
 | 
					            </Box>
 | 
				
			||||||
          </PageContent>
 | 
					          </PageContent>
 | 
				
			||||||
        </Scroll>
 | 
					        </Scroll>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										10
									
								
								src/app/hooks/useReportRoomSupported.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/hooks/useReportRoomSupported.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -32,7 +32,7 @@ function InvitesNavItem() {
 | 
				
			||||||
            </Avatar>
 | 
					            </Avatar>
 | 
				
			||||||
            <Box as="span" grow="Yes">
 | 
					            <Box as="span" grow="Yes">
 | 
				
			||||||
              <Text as="span" size="Inherit" truncate>
 | 
					              <Text as="span" size="Inherit" truncate>
 | 
				
			||||||
                Invitations
 | 
					                Invites
 | 
				
			||||||
              </Text>
 | 
					              </Text>
 | 
				
			||||||
            </Box>
 | 
					            </Box>
 | 
				
			||||||
            {inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
 | 
					            {inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,10 @@
 | 
				
			||||||
import React, { useCallback, useRef, useState } from 'react';
 | 
					import React, { useCallback, useMemo, useRef, useState } from 'react';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Avatar,
 | 
					  Avatar,
 | 
				
			||||||
 | 
					  Badge,
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
  Button,
 | 
					  Button,
 | 
				
			||||||
 | 
					  Chip,
 | 
				
			||||||
  Icon,
 | 
					  Icon,
 | 
				
			||||||
  IconButton,
 | 
					  IconButton,
 | 
				
			||||||
  Icons,
 | 
					  Icons,
 | 
				
			||||||
| 
						 | 
					@ -16,56 +18,129 @@ import {
 | 
				
			||||||
  config,
 | 
					  config,
 | 
				
			||||||
} from 'folds';
 | 
					} from 'folds';
 | 
				
			||||||
import { useAtomValue } from 'jotai';
 | 
					import { useAtomValue } from 'jotai';
 | 
				
			||||||
 | 
					import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
 | 
				
			||||||
import FocusTrap from 'focus-trap-react';
 | 
					import FocusTrap from 'focus-trap-react';
 | 
				
			||||||
import { MatrixError, Room } from 'matrix-js-sdk';
 | 
					import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
 | 
				
			||||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
 | 
					import {
 | 
				
			||||||
import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList';
 | 
					  Page,
 | 
				
			||||||
 | 
					  PageContent,
 | 
				
			||||||
 | 
					  PageContentCenter,
 | 
				
			||||||
 | 
					  PageHeader,
 | 
				
			||||||
 | 
					  PageHero,
 | 
				
			||||||
 | 
					  PageHeroEmpty,
 | 
				
			||||||
 | 
					  PageHeroSection,
 | 
				
			||||||
 | 
					} from '../../../components/page';
 | 
				
			||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
				
			||||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
 | 
					import { allInvitesAtom } from '../../../state/room-list/inviteList';
 | 
				
			||||||
import { mDirectAtom } from '../../../state/mDirectList';
 | 
					 | 
				
			||||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
					import { SequenceCard } from '../../../components/sequence-card';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  bannedInRooms,
 | 
				
			||||||
 | 
					  getCommonRooms,
 | 
				
			||||||
  getDirectRoomAvatarUrl,
 | 
					  getDirectRoomAvatarUrl,
 | 
				
			||||||
  getMemberDisplayName,
 | 
					  getMemberDisplayName,
 | 
				
			||||||
  getRoomAvatarUrl,
 | 
					  getRoomAvatarUrl,
 | 
				
			||||||
 | 
					  getStateEvent,
 | 
				
			||||||
  isDirectInvite,
 | 
					  isDirectInvite,
 | 
				
			||||||
 | 
					  isSpace,
 | 
				
			||||||
} from '../../../utils/room';
 | 
					} from '../../../utils/room';
 | 
				
			||||||
import { nameInitials } from '../../../utils/common';
 | 
					import { nameInitials } from '../../../utils/common';
 | 
				
			||||||
import { RoomAvatar } from '../../../components/room-avatar';
 | 
					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 { Time } from '../../../components/message';
 | 
				
			||||||
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
 | 
					import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
 | 
				
			||||||
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
 | 
					import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
 | 
				
			||||||
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
 | 
					import { RoomTopicViewer } from '../../../components/room-topic-viewer';
 | 
				
			||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
					import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
				
			||||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
 | 
					import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
 | 
				
			||||||
import { useRoomTopic } from '../../../hooks/useRoomMeta';
 | 
					 | 
				
			||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 | 
					import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 | 
				
			||||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
 | 
					import { BackRouteHandler } from '../../../components/BackRouteHandler';
 | 
				
			||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
					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;
 | 
					const COMPACT_CARD_WIDTH = 548;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type InviteCardProps = {
 | 
					type InviteData = {
 | 
				
			||||||
  room: Room;
 | 
					  room: Room;
 | 
				
			||||||
  userId: string;
 | 
					  roomId: string;
 | 
				
			||||||
  direct?: boolean;
 | 
					  roomName: string;
 | 
				
			||||||
  compact?: boolean;
 | 
					  roomAvatar?: string;
 | 
				
			||||||
  onNavigate: (roomId: string) => void;
 | 
					  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 makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => {
 | 
				
			||||||
  const useAuthentication = useMediaAuthentication();
 | 
					  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 roomName = room.name || room.getCanonicalAlias() || room.roomId;
 | 
				
			||||||
 | 
					  const roomTopic =
 | 
				
			||||||
 | 
					    getStateEvent(room, StateEvent.RoomTopic)?.getContent<RoomTopicEventContent>()?.topic ??
 | 
				
			||||||
 | 
					    undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const member = room.getMember(userId);
 | 
					  const member = room.getMember(userId);
 | 
				
			||||||
  const memberEvent = member?.events.member;
 | 
					  const memberEvent = member?.events.member;
 | 
				
			||||||
  const memberTs = memberEvent?.getTs() ?? 0;
 | 
					
 | 
				
			||||||
  const senderId = memberEvent?.getSender();
 | 
					  const senderId = memberEvent?.getSender();
 | 
				
			||||||
  const senderName = senderId
 | 
					  const senderName = senderId
 | 
				
			||||||
    ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
 | 
					    ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
 | 
				
			||||||
    : undefined;
 | 
					    : undefined;
 | 
				
			||||||
 | 
					  const inviteTs = memberEvent?.getTs() ?? 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const topic = useRoomTopic(room);
 | 
					  return {
 | 
				
			||||||
 | 
					    room,
 | 
				
			||||||
 | 
					    roomId: room.roomId,
 | 
				
			||||||
 | 
					    roomAvatar,
 | 
				
			||||||
 | 
					    roomName,
 | 
				
			||||||
 | 
					    roomTopic,
 | 
				
			||||||
 | 
					    roomAlias: room.getCanonicalAlias() ?? undefined,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    senderId: senderId ?? 'Unknown',
 | 
				
			||||||
 | 
					    senderName: senderName ?? 'Unknown',
 | 
				
			||||||
 | 
					    inviteTs,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isSpace: isSpace(room),
 | 
				
			||||||
 | 
					    isDirect: direct,
 | 
				
			||||||
 | 
					    isEncrypted: !!getStateEvent(room, StateEvent.RoomEncryption),
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const hasBadWords = (invite: InviteData): boolean =>
 | 
				
			||||||
 | 
					  testBadWords(invite.roomName) ||
 | 
				
			||||||
 | 
					  testBadWords(invite.roomTopic ?? '') ||
 | 
				
			||||||
 | 
					  testBadWords(invite.senderName) ||
 | 
				
			||||||
 | 
					  testBadWords(invite.senderId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type NavigateHandler = (roomId: string, space: boolean) => void;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type InviteCardProps = {
 | 
				
			||||||
 | 
					  invite: InviteData;
 | 
				
			||||||
 | 
					  compact?: boolean;
 | 
				
			||||||
 | 
					  onNavigate: NavigateHandler;
 | 
				
			||||||
 | 
					  hideAvatar: boolean;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					function InviteCard({ invite, compact, onNavigate, hideAvatar }: InviteCardProps) {
 | 
				
			||||||
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
 | 
					  const userId = mx.getSafeUserId();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [viewTopic, setViewTopic] = useState(false);
 | 
					  const [viewTopic, setViewTopic] = useState(false);
 | 
				
			||||||
  const closeTopic = () => setViewTopic(false);
 | 
					  const closeTopic = () => setViewTopic(false);
 | 
				
			||||||
| 
						 | 
					@ -73,17 +148,19 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
 | 
					  const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
 | 
				
			||||||
    useCallback(async () => {
 | 
					    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) {
 | 
					      if (dmUserId) {
 | 
				
			||||||
        await addRoomIdToMDirect(mx, room.roomId, dmUserId);
 | 
					        await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      onNavigate(room.roomId);
 | 
					      onNavigate(invite.roomId, invite.isSpace);
 | 
				
			||||||
    }, [mx, room, userId, onNavigate])
 | 
					    }, [mx, invite, userId, onNavigate])
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
  const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
 | 
					  const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
 | 
				
			||||||
    useCallback(() => mx.leave(room.roomId), [mx, room])
 | 
					    useCallback(() => mx.leave(invite.roomId), [mx, invite])
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const joining =
 | 
					  const joining =
 | 
				
			||||||
| 
						 | 
					@ -95,28 +172,43 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
 | 
				
			||||||
    <SequenceCard
 | 
					    <SequenceCard
 | 
				
			||||||
      variant="SurfaceVariant"
 | 
					      variant="SurfaceVariant"
 | 
				
			||||||
      direction="Column"
 | 
					      direction="Column"
 | 
				
			||||||
      gap="200"
 | 
					      gap="300"
 | 
				
			||||||
      style={{ padding: config.space.S400, paddingTop: config.space.S200 }}
 | 
					      style={{ padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}` }}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <Box gap="200" alignItems="Baseline">
 | 
					      {(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
 | 
				
			||||||
        <Box grow="Yes">
 | 
					        <Box gap="200" alignItems="Center">
 | 
				
			||||||
          <Text size="T200" priority="300" truncate>
 | 
					          {invite.isEncrypted && (
 | 
				
			||||||
            Invited by <b>{senderName}</b>
 | 
					            <Box shrink="No" alignItems="Center" justifyContent="Center">
 | 
				
			||||||
          </Text>
 | 
					              <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 shrink="No">
 | 
					      )}
 | 
				
			||||||
          <Time size="T200" ts={memberTs} priority="300" />
 | 
					 | 
				
			||||||
        </Box>
 | 
					 | 
				
			||||||
      </Box>
 | 
					 | 
				
			||||||
      <Box gap="300">
 | 
					      <Box gap="300">
 | 
				
			||||||
        <Avatar size="300">
 | 
					        <Avatar size="300">
 | 
				
			||||||
          <RoomAvatar
 | 
					          <RoomAvatar
 | 
				
			||||||
            roomId={room.roomId}
 | 
					            roomId={invite.roomId}
 | 
				
			||||||
            src={direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)}
 | 
					            src={hideAvatar ? undefined : invite.roomAvatar}
 | 
				
			||||||
            alt={roomName}
 | 
					            alt={invite.roomName}
 | 
				
			||||||
            renderFallback={() => (
 | 
					            renderFallback={() => (
 | 
				
			||||||
              <Text as="span" size="H6">
 | 
					              <Text as="span" size="H6">
 | 
				
			||||||
                {nameInitials(roomName)}
 | 
					                {nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
 | 
				
			||||||
              </Text>
 | 
					              </Text>
 | 
				
			||||||
            )}
 | 
					            )}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
| 
						 | 
					@ -125,9 +217,9 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
 | 
				
			||||||
          <Box grow="Yes" direction="Column" gap="200">
 | 
					          <Box grow="Yes" direction="Column" gap="200">
 | 
				
			||||||
            <Box direction="Column">
 | 
					            <Box direction="Column">
 | 
				
			||||||
              <Text size="T300" truncate>
 | 
					              <Text size="T300" truncate>
 | 
				
			||||||
                <b>{roomName}</b>
 | 
					                <b>{invite.roomName}</b>
 | 
				
			||||||
              </Text>
 | 
					              </Text>
 | 
				
			||||||
              {topic && (
 | 
					              {invite.roomTopic && (
 | 
				
			||||||
                <Text
 | 
					                <Text
 | 
				
			||||||
                  size="T200"
 | 
					                  size="T200"
 | 
				
			||||||
                  onClick={openTopic}
 | 
					                  onClick={openTopic}
 | 
				
			||||||
| 
						 | 
					@ -135,7 +227,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
 | 
				
			||||||
                  tabIndex={0}
 | 
					                  tabIndex={0}
 | 
				
			||||||
                  truncate
 | 
					                  truncate
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                  {topic}
 | 
					                  {invite.roomTopic}
 | 
				
			||||||
                </Text>
 | 
					                </Text>
 | 
				
			||||||
              )}
 | 
					              )}
 | 
				
			||||||
              <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
 | 
					              <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
 | 
				
			||||||
| 
						 | 
					@ -149,8 +241,8 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
 | 
				
			||||||
                    }}
 | 
					                    }}
 | 
				
			||||||
                  >
 | 
					                  >
 | 
				
			||||||
                    <RoomTopicViewer
 | 
					                    <RoomTopicViewer
 | 
				
			||||||
                      name={roomName}
 | 
					                      name={invite.roomName}
 | 
				
			||||||
                      topic={topic ?? ''}
 | 
					                      topic={invite.roomTopic ?? ''}
 | 
				
			||||||
                      requestClose={closeTopic}
 | 
					                      requestClose={closeTopic}
 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
                  </FocusTrap>
 | 
					                  </FocusTrap>
 | 
				
			||||||
| 
						 | 
					@ -173,6 +265,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
 | 
				
			||||||
              onClick={leave}
 | 
					              onClick={leave}
 | 
				
			||||||
              size="300"
 | 
					              size="300"
 | 
				
			||||||
              variant="Secondary"
 | 
					              variant="Secondary"
 | 
				
			||||||
 | 
					              radii="300"
 | 
				
			||||||
              fill="Soft"
 | 
					              fill="Soft"
 | 
				
			||||||
              disabled={joining || leaving}
 | 
					              disabled={joining || leaving}
 | 
				
			||||||
              before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
 | 
					              before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
 | 
				
			||||||
| 
						 | 
					@ -182,28 +275,392 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
 | 
				
			||||||
            <Button
 | 
					            <Button
 | 
				
			||||||
              onClick={join}
 | 
					              onClick={join}
 | 
				
			||||||
              size="300"
 | 
					              size="300"
 | 
				
			||||||
              variant="Primary"
 | 
					              variant="Success"
 | 
				
			||||||
              fill="Soft"
 | 
					              fill="Soft"
 | 
				
			||||||
 | 
					              radii="300"
 | 
				
			||||||
              outlined
 | 
					              outlined
 | 
				
			||||||
              disabled={joining || leaving}
 | 
					              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>
 | 
					              <Text size="B300">Accept</Text>
 | 
				
			||||||
            </Button>
 | 
					            </Button>
 | 
				
			||||||
          </Box>
 | 
					          </Box>
 | 
				
			||||||
        </Box>
 | 
					        </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>
 | 
					    </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() {
 | 
					export function Invites() {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const userId = mx.getUserId()!;
 | 
					  const useAuthentication = useMediaAuthentication();
 | 
				
			||||||
  const mDirects = useAtomValue(mDirectAtom);
 | 
					  const { navigateRoom, navigateSpace } = useRoomNavigate();
 | 
				
			||||||
  const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects);
 | 
					  const allRooms = useAtomValue(allRoomsAtom);
 | 
				
			||||||
  const spaceInvites = useSpaceInvites(mx, allInvitesAtom);
 | 
					  const allInviteIds = useAtomValue(allInvitesAtom);
 | 
				
			||||||
  const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
 | 
					
 | 
				
			||||||
 | 
					  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 containerRef = useRef<HTMLDivElement>(null);
 | 
				
			||||||
  const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
 | 
					  const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
 | 
				
			||||||
  useElementSizeObserver(
 | 
					  useElementSizeObserver(
 | 
				
			||||||
| 
						 | 
					@ -212,21 +669,12 @@ export function Invites() {
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
  const screenSize = useScreenSizeContext();
 | 
					  const screenSize = useScreenSizeContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { navigateRoom, navigateSpace } = useRoomNavigate();
 | 
					  const handleNavigate = (roomId: string, space: boolean) => {
 | 
				
			||||||
 | 
					    if (space) {
 | 
				
			||||||
  const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => {
 | 
					      navigateSpace(roomId);
 | 
				
			||||||
    const room = mx.getRoom(roomId);
 | 
					      return;
 | 
				
			||||||
    if (!room) return null;
 | 
					    }
 | 
				
			||||||
    return (
 | 
					    navigateRoom(roomId);
 | 
				
			||||||
      <InviteCard
 | 
					 | 
				
			||||||
        key={roomId}
 | 
					 | 
				
			||||||
        room={room}
 | 
					 | 
				
			||||||
        userId={userId}
 | 
					 | 
				
			||||||
        compact={compact}
 | 
					 | 
				
			||||||
        direct={direct}
 | 
					 | 
				
			||||||
        onNavigate={handleNavigate}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					@ -247,7 +695,7 @@ export function Invites() {
 | 
				
			||||||
          <Box alignItems="Center" gap="200">
 | 
					          <Box alignItems="Center" gap="200">
 | 
				
			||||||
            {screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
 | 
					            {screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
 | 
				
			||||||
            <Text size="H3" truncate>
 | 
					            <Text size="H3" truncate>
 | 
				
			||||||
              Invitations
 | 
					              Invites
 | 
				
			||||||
            </Text>
 | 
					            </Text>
 | 
				
			||||||
          </Box>
 | 
					          </Box>
 | 
				
			||||||
          <Box grow="Yes" basis="No" />
 | 
					          <Box grow="Yes" basis="No" />
 | 
				
			||||||
| 
						 | 
					@ -258,47 +706,40 @@ export function Invites() {
 | 
				
			||||||
          <PageContent>
 | 
					          <PageContent>
 | 
				
			||||||
            <PageContentCenter>
 | 
					            <PageContentCenter>
 | 
				
			||||||
              <Box ref={containerRef} direction="Column" gap="600">
 | 
					              <Box ref={containerRef} direction="Column" gap="600">
 | 
				
			||||||
                {directInvites.length > 0 && (
 | 
					                <Box direction="Column" gap="100">
 | 
				
			||||||
                  <Box direction="Column" gap="200">
 | 
					                  <span data-spacing-node />
 | 
				
			||||||
                    <Text size="H4">Direct Messages</Text>
 | 
					                  <Text size="L400">Filter</Text>
 | 
				
			||||||
                    <Box direction="Column" gap="100">
 | 
					                  <InviteFilters
 | 
				
			||||||
                      {directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))}
 | 
					                    filter={filter}
 | 
				
			||||||
                    </Box>
 | 
					                    onFilter={setFilter}
 | 
				
			||||||
                  </Box>
 | 
					                    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">
 | 
					                {filter === InviteFilter.Unknown && (
 | 
				
			||||||
                    <Text size="H4">Spaces</Text>
 | 
					                  <UnknownInvites
 | 
				
			||||||
                    <Box direction="Column" gap="100">
 | 
					                    invites={unknownInvites}
 | 
				
			||||||
                      {spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))}
 | 
					                    compact={compact}
 | 
				
			||||||
                    </Box>
 | 
					                    handleNavigate={handleNavigate}
 | 
				
			||||||
                  </Box>
 | 
					                  />
 | 
				
			||||||
                )}
 | 
					                )}
 | 
				
			||||||
                {roomInvites.length > 0 && (
 | 
					
 | 
				
			||||||
                  <Box direction="Column" gap="200">
 | 
					                {filter === InviteFilter.Spam && (
 | 
				
			||||||
                    <Text size="H4">Rooms</Text>
 | 
					                  <SpamInvites
 | 
				
			||||||
                    <Box direction="Column" gap="100">
 | 
					                    invites={spamInvites}
 | 
				
			||||||
                      {roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))}
 | 
					                    compact={compact}
 | 
				
			||||||
                    </Box>
 | 
					                    handleNavigate={handleNavigate}
 | 
				
			||||||
                  </Box>
 | 
					                  />
 | 
				
			||||||
                )}
 | 
					                )}
 | 
				
			||||||
                {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>
 | 
					              </Box>
 | 
				
			||||||
            </PageContentCenter>
 | 
					            </PageContentCenter>
 | 
				
			||||||
          </PageContent>
 | 
					          </PageContent>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										15
									
								
								src/app/plugins/bad-words.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/app/plugins/bad-words.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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);
 | 
				
			||||||
| 
						 | 
					@ -304,6 +304,14 @@ export const rateLimitedActions = async <T, R = void>(
 | 
				
			||||||
  maxRetryCount?: number
 | 
					  maxRetryCount?: number
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  let retryCount = 0;
 | 
					  let retryCount = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let actionInterval = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const sleepForMs = (ms: number) =>
 | 
				
			||||||
 | 
					    new Promise((resolve) => {
 | 
				
			||||||
 | 
					      setTimeout(resolve, ms);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const performAction = async (dataItem: T) => {
 | 
					  const performAction = async (dataItem: T) => {
 | 
				
			||||||
    const [err] = await to<R, MatrixError>(callback(dataItem));
 | 
					    const [err] = await to<R, MatrixError>(callback(dataItem));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -312,10 +320,9 @@ export const rateLimitedActions = async <T, R = void>(
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const waitMS = err.getRetryAfterMs() ?? 200;
 | 
					      const waitMS = err.getRetryAfterMs() ?? 3000;
 | 
				
			||||||
      await new Promise((resolve) => {
 | 
					      actionInterval = waitMS + 500;
 | 
				
			||||||
        setTimeout(resolve, waitMS);
 | 
					      await sleepForMs(waitMS);
 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      retryCount += 1;
 | 
					      retryCount += 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await performAction(dataItem);
 | 
					      await performAction(dataItem);
 | 
				
			||||||
| 
						 | 
					@ -327,5 +334,9 @@ export const rateLimitedActions = async <T, R = void>(
 | 
				
			||||||
    retryCount = 0;
 | 
					    retryCount = 0;
 | 
				
			||||||
    // eslint-disable-next-line no-await-in-loop
 | 
					    // eslint-disable-next-line no-await-in-loop
 | 
				
			||||||
    await performAction(dataItem);
 | 
					    await performAction(dataItem);
 | 
				
			||||||
 | 
					    if (actionInterval > 0) {
 | 
				
			||||||
 | 
					      // eslint-disable-next-line no-await-in-loop
 | 
				
			||||||
 | 
					      await sleepForMs(actionInterval);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,6 +19,7 @@ import {
 | 
				
			||||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
 | 
					import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
 | 
				
			||||||
import { AccountDataEvent } from '../../types/matrix/accountData';
 | 
					import { AccountDataEvent } from '../../types/matrix/accountData';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  Membership,
 | 
				
			||||||
  MessageEvent,
 | 
					  MessageEvent,
 | 
				
			||||||
  NotificationType,
 | 
					  NotificationType,
 | 
				
			||||||
  RoomToParents,
 | 
					  RoomToParents,
 | 
				
			||||||
| 
						 | 
					@ -171,7 +172,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!roomPushRule) {
 | 
					  if (!roomPushRule) {
 | 
				
			||||||
    const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
 | 
					    const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>()
 | 
				
			||||||
      ?.global?.override;
 | 
					      ?.global?.override;
 | 
				
			||||||
    if (!overrideRules) return NotificationType.Default;
 | 
					    if (!overrideRules) return NotificationType.Default;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -443,3 +444,32 @@ export const getMentionContent = (userIds: string[], room: boolean): IMentions =
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return mMentions;
 | 
					  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;
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue