mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	redesigned app settings and switch to rust crypto (#1988)
* rework general settings * account settings - WIP * add missing key prop * add object url hook * extract wide modal styles * profile settings and image editor - WIP * add outline style to upload card * remove file param from bind upload atom hook * add compact variant to upload card * add compact upload card renderer * add option to update profile avatar * add option to change profile displayname * allow displayname change based on capabilities check * rearrange settings components into folders * add system notification settings * add initial page param in settings * convert account data hook to typescript * add push rule hook * add notification mode hook * add notification mode switcher component * add all messages notification settings options * add special messages notification settings * add keyword notifications * add ignored users section * improve ignore user list strings * add about settings * add access token option in about settings * add developer tools settings * add expand button to account data dev tool option * update folds * fix editable active element textarea check * do not close dialog when editable element in focus * add text area plugins * add text area intent handler hook * add newline intent mod in text area * add next line hotkey in text area intent hook * add syntax error position dom utility function * add account data editor * add button to send new account data in dev tools * improve custom emoji plugin * add more custom emojis hooks * add text util css * add word break in setting tile title and description * emojis and sticker user settings - WIP * view image packs from settings * emoji pack editing - WIP * add option to edit pack meta * change saved changes message * add image edit and delete controls * add option to upload pack images and apply changes * fix state event type when updating image pack * lazy load pack image tile img * hide upload image button when user can not edit pack * add option to add or remove global image packs * upgrade to rust crypto (#2168) * update matrix js sdk * remove dead code * use rust crypto * update setPowerLevel usage * fix types * fix deprecated isRoomEncrypted method uses * fix deprecated room.currentState uses * fix deprecated import/export room keys func * fix merge issues in image pack file * fix remaining issues in image pack file * start indexedDBStore * update package lock and vite-plugin-top-level-await * user session settings - WIP * add useAsync hook * add password stage uia * add uia flow matrix error hook * add UIA action component * add options to delete sessions * add sso uia stage * fix SSO stage complete error * encryption - WIP * update user settings encryption terminology * add default variant to password input * use password input in uia password stage * add options for local backup in user settings * remove typo in import local backup password input label * online backup - WIP * fix uia sso action * move access token settings from about to developer tools * merge encryption tab into sessions and rename it to devices * add device placeholder tile * add logout dialog * add logout button for current device * move other devices in component * render unverified device verification tile * add learn more section for current device verification * add device verification status badge * add info card component * add index file for password input component * add types for secret storage * add component to access secret storage key * manual verification - WIP * update matrix-js-sdk to v35 * add manual verification * use react query for device list * show unverified tab on sidebar * fix device list updates * add session key details to current device * render restore encryption backup * fix loading state of restore backup * fix unverified tab settings closes after verification * key backup tile - WIP * fix unverified tab badge * rename session key to device key in device tile * improve backup restore functionality * fix restore button enabled after layout reload during restoring backup * update backup info on status change * add backup disconnection failures * add device verification using sas * restore backup after verification * show option to logout on startup error screen * fix key backup hook update on decryption key cached * add option to enable device verification * add device verification reset dialog * add logout button in settings drawer * add encrypted message lost on logout * fix backup restore never finish with 0 keys * fix setup dialog hides when enabling device verification * show backup details in menu * update setup device verification body copy * replace deprecated method * fix displayname appear as mxid in settings * remove old refactored codes * fix types
This commit is contained in:
		
							parent
							
								
									f5d68fcc22
								
							
						
					
					
						commit
						56b754153a
					
				
					 196 changed files with 14171 additions and 8403 deletions
				
			
		
							
								
								
									
										7385
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										7385
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							| 
						 | 
				
			
			@ -44,7 +44,7 @@
 | 
			
		|||
    "file-saver": "2.0.5",
 | 
			
		||||
    "flux": "4.0.3",
 | 
			
		||||
    "focus-trap-react": "10.0.2",
 | 
			
		||||
    "folds": "2.0.0",
 | 
			
		||||
    "folds": "2.1.0",
 | 
			
		||||
    "formik": "2.4.6",
 | 
			
		||||
    "html-dom-parser": "4.0.0",
 | 
			
		||||
    "html-react-parser": "4.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +56,7 @@
 | 
			
		|||
    "jotai": "2.6.0",
 | 
			
		||||
    "linkify-react": "4.1.3",
 | 
			
		||||
    "linkifyjs": "4.1.3",
 | 
			
		||||
    "matrix-js-sdk": "34.11.1",
 | 
			
		||||
    "matrix-js-sdk": "35.0.0",
 | 
			
		||||
    "millify": "6.1.0",
 | 
			
		||||
    "pdfjs-dist": "4.2.67",
 | 
			
		||||
    "prismjs": "1.29.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -108,6 +108,6 @@
 | 
			
		|||
    "vite": "5.0.13",
 | 
			
		||||
    "vite-plugin-pwa": "0.20.5",
 | 
			
		||||
    "vite-plugin-static-copy": "1.0.4",
 | 
			
		||||
    "vite-plugin-top-level-await": "1.4.1"
 | 
			
		||||
    "vite-plugin-top-level-await": "1.4.4"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										73
									
								
								src/app/components/ActionUIA.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/app/components/ActionUIA.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,73 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
 | 
			
		||||
import { getUIAFlowForStages } from '../utils/matrix-uia';
 | 
			
		||||
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
 | 
			
		||||
import { UIAFlowOverlay } from './UIAFlowOverlay';
 | 
			
		||||
import { PasswordStage, SSOStage } from './uia-stages';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
 | 
			
		||||
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso];
 | 
			
		||||
 | 
			
		||||
export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => {
 | 
			
		||||
  const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]);
 | 
			
		||||
  if (passwordFlow) return passwordFlow;
 | 
			
		||||
  return getUIAFlowForStages(uiaFlows, [AuthType.Sso]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ActionUIAProps = {
 | 
			
		||||
  authData: IAuthData;
 | 
			
		||||
  ongoingFlow: UIAFlow;
 | 
			
		||||
  action: (authDict: AuthDict) => void;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const completed = useUIACompleted(authData);
 | 
			
		||||
  const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
 | 
			
		||||
 | 
			
		||||
  const stageToComplete = getStageToComplete();
 | 
			
		||||
 | 
			
		||||
  if (!stageToComplete) return null;
 | 
			
		||||
  return (
 | 
			
		||||
    <UIAFlowOverlay
 | 
			
		||||
      currentStep={completed.length + 1}
 | 
			
		||||
      stepCount={ongoingFlow.stages.length}
 | 
			
		||||
      onCancel={onCancel}
 | 
			
		||||
    >
 | 
			
		||||
      {stageToComplete.type === AuthType.Password && (
 | 
			
		||||
        <PasswordStage
 | 
			
		||||
          userId={mx.getUserId()!}
 | 
			
		||||
          stageData={stageToComplete}
 | 
			
		||||
          onCancel={onCancel}
 | 
			
		||||
          submitAuthDict={action}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {stageToComplete.type === AuthType.Sso && stageToComplete.session && (
 | 
			
		||||
        <SSOStage
 | 
			
		||||
          ssoRedirectURL={mx.getFallbackAuthUrl(AuthType.Sso, stageToComplete.session)}
 | 
			
		||||
          stageData={stageToComplete}
 | 
			
		||||
          onCancel={onCancel}
 | 
			
		||||
          submitAuthDict={action}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </UIAFlowOverlay>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ActionUIAFlowsLoaderProps = {
 | 
			
		||||
  authData: IAuthData;
 | 
			
		||||
  unsupported: () => ReactNode;
 | 
			
		||||
  children: (ongoingFlow: UIAFlow) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function ActionUIAFlowsLoader({
 | 
			
		||||
  authData,
 | 
			
		||||
  unsupported,
 | 
			
		||||
  children,
 | 
			
		||||
}: ActionUIAFlowsLoaderProps) {
 | 
			
		||||
  const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
 | 
			
		||||
  const ongoingFlow = supportedFlows.length > 0 ? supportedFlows[0] : undefined;
 | 
			
		||||
 | 
			
		||||
  if (!ongoingFlow) return unsupported();
 | 
			
		||||
 | 
			
		||||
  return children(ongoingFlow);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										281
									
								
								src/app/components/BackupRestore.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										281
									
								
								src/app/components/BackupRestore.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,281 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useState } from 'react';
 | 
			
		||||
import { useAtom } from 'jotai';
 | 
			
		||||
import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import {
 | 
			
		||||
  Badge,
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  color,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Menu,
 | 
			
		||||
  percent,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  ProgressBar,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
 | 
			
		||||
import { InfoCard } from './info-card';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import {
 | 
			
		||||
  useKeyBackupInfo,
 | 
			
		||||
  useKeyBackupStatus,
 | 
			
		||||
  useKeyBackupSync,
 | 
			
		||||
  useKeyBackupTrust,
 | 
			
		||||
} from '../hooks/useKeyBackup';
 | 
			
		||||
import { stopPropagation } from '../utils/keyboard';
 | 
			
		||||
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
 | 
			
		||||
 | 
			
		||||
type BackupStatusProps = {
 | 
			
		||||
  enabled: boolean;
 | 
			
		||||
};
 | 
			
		||||
function BackupStatus({ enabled }: BackupStatusProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="span" gap="100" alignItems="Center">
 | 
			
		||||
      <Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
 | 
			
		||||
      <Text
 | 
			
		||||
        as="span"
 | 
			
		||||
        size="L400"
 | 
			
		||||
        style={{ color: enabled ? color.Success.Main : color.Critical.Main }}
 | 
			
		||||
      >
 | 
			
		||||
        {enabled ? 'Connected' : 'Disconnected'}
 | 
			
		||||
      </Text>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
type BackupSyncingProps = {
 | 
			
		||||
  count: number;
 | 
			
		||||
};
 | 
			
		||||
function BackupSyncing({ count }: BackupSyncingProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="span" gap="100" alignItems="Center">
 | 
			
		||||
      <Spinner size="50" variant="Primary" fill="Soft" />
 | 
			
		||||
      <Text as="span" size="L400" style={{ color: color.Primary.Main }}>
 | 
			
		||||
        Syncing ({count})
 | 
			
		||||
      </Text>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function BackupProgressFetching() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" gap="200" alignItems="Center">
 | 
			
		||||
      <Badge variant="Secondary" fill="Solid" radii="300">
 | 
			
		||||
        <Text size="L400">Restoring: 0%</Text>
 | 
			
		||||
      </Badge>
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
        <ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Spinner size="50" variant="Secondary" fill="Soft" />
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type BackupProgressProps = {
 | 
			
		||||
  total: number;
 | 
			
		||||
  downloaded: number;
 | 
			
		||||
};
 | 
			
		||||
function BackupProgress({ total, downloaded }: BackupProgressProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" gap="200" alignItems="Center">
 | 
			
		||||
      <Badge variant="Secondary" fill="Solid" radii="300">
 | 
			
		||||
        <Text size="L400">Restoring: {`${Math.round(percent(0, total, downloaded))}%`}</Text>
 | 
			
		||||
      </Badge>
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
        <ProgressBar variant="Secondary" size="300" min={0} max={total} value={downloaded} />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Badge variant="Secondary" fill="Soft" radii="Pill">
 | 
			
		||||
        <Text size="L400">
 | 
			
		||||
          {downloaded} / {total}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Badge>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type BackupTrustInfoProps = {
 | 
			
		||||
  crypto: CryptoApi;
 | 
			
		||||
  backupInfo: KeyBackupInfo;
 | 
			
		||||
};
 | 
			
		||||
function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {
 | 
			
		||||
  const trust = useKeyBackupTrust(crypto, backupInfo);
 | 
			
		||||
 | 
			
		||||
  if (!trust) return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column">
 | 
			
		||||
      {trust.matchesDecryptionKey ? (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Success.Main }}>
 | 
			
		||||
          <b>Backup has trusted decryption key.</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          <b>Backup does not have trusted decryption key!</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
      {trust.trusted ? (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Success.Main }}>
 | 
			
		||||
          <b>Backup has trusted by signature.</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          <b>Backup does not have trusted signature!</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type BackupRestoreTileProps = {
 | 
			
		||||
  crypto: CryptoApi;
 | 
			
		||||
};
 | 
			
		||||
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
 | 
			
		||||
  const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom);
 | 
			
		||||
  const restoring =
 | 
			
		||||
    restoreProgress.status === BackupProgressStatus.Fetching ||
 | 
			
		||||
    restoreProgress.status === BackupProgressStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const backupEnabled = useKeyBackupStatus(crypto);
 | 
			
		||||
  const backupInfo = useKeyBackupInfo(crypto);
 | 
			
		||||
  const [remainingSession, syncFailure] = useKeyBackupSync();
 | 
			
		||||
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const [restoreState, restoreBackup] = useAsyncCallback<void, Error, []>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      await crypto.restoreKeyBackup({
 | 
			
		||||
        progressCallback(progress) {
 | 
			
		||||
          setRestoreProgress(progress);
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
    }, [crypto, setRestoreProgress])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRestore = () => {
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
    restoreBackup();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <InfoCard
 | 
			
		||||
      variant="Surface"
 | 
			
		||||
      title="Encryption Backup"
 | 
			
		||||
      after={
 | 
			
		||||
        <Box alignItems="Center" gap="200">
 | 
			
		||||
          {remainingSession === 0 ? (
 | 
			
		||||
            <BackupStatus enabled={backupEnabled} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <BackupSyncing count={remainingSession} />
 | 
			
		||||
          )}
 | 
			
		||||
          <IconButton
 | 
			
		||||
            aria-pressed={!!menuCords}
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Surface"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            onClick={handleMenu}
 | 
			
		||||
          >
 | 
			
		||||
            <Icon size="100" src={Icons.VerticalDots} />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
          <PopOut
 | 
			
		||||
            anchor={menuCords}
 | 
			
		||||
            offset={5}
 | 
			
		||||
            position="Bottom"
 | 
			
		||||
            align="End"
 | 
			
		||||
            content={
 | 
			
		||||
              <FocusTrap
 | 
			
		||||
                focusTrapOptions={{
 | 
			
		||||
                  initialFocus: false,
 | 
			
		||||
                  onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
                  clickOutsideDeactivates: true,
 | 
			
		||||
                  isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                    evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                  isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                    evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                  escapeDeactivates: stopPropagation,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Menu
 | 
			
		||||
                  style={{
 | 
			
		||||
                    padding: config.space.S100,
 | 
			
		||||
                  }}
 | 
			
		||||
                >
 | 
			
		||||
                  <Box direction="Column" gap="100">
 | 
			
		||||
                    <Box direction="Column" gap="200">
 | 
			
		||||
                      <InfoCard
 | 
			
		||||
                        variant="SurfaceVariant"
 | 
			
		||||
                        title="Backup Details"
 | 
			
		||||
                        description={
 | 
			
		||||
                          <>
 | 
			
		||||
                            <span>Version: {backupInfo?.version ?? 'NIL'}</span>
 | 
			
		||||
                            <br />
 | 
			
		||||
                            <span>Keys: {backupInfo?.count ?? 'NIL'}</span>
 | 
			
		||||
                          </>
 | 
			
		||||
                        }
 | 
			
		||||
                      />
 | 
			
		||||
                    </Box>
 | 
			
		||||
                    <Button
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      variant="Success"
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      aria-disabled={restoreState.status === AsyncStatus.Loading || restoring}
 | 
			
		||||
                      onClick={
 | 
			
		||||
                        restoreState.status === AsyncStatus.Loading || restoring
 | 
			
		||||
                          ? undefined
 | 
			
		||||
                          : handleRestore
 | 
			
		||||
                      }
 | 
			
		||||
                      before={<Icon size="100" src={Icons.Download} />}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Restore Backup</Text>
 | 
			
		||||
                    </Button>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Menu>
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {syncFailure && (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          <b>{syncFailure}</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
      {!backupEnabled && backupInfo === null && (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          <b>No backup present on server!</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
      {!syncFailure && !backupEnabled && backupInfo && (
 | 
			
		||||
        <BackupTrustInfo crypto={crypto} backupInfo={backupInfo} />
 | 
			
		||||
      )}
 | 
			
		||||
      {restoreState.status === AsyncStatus.Loading && !restoring && <BackupProgressFetching />}
 | 
			
		||||
      {restoreProgress.status === BackupProgressStatus.Fetching && <BackupProgressFetching />}
 | 
			
		||||
      {restoreProgress.status === BackupProgressStatus.Loading && (
 | 
			
		||||
        <BackupProgress
 | 
			
		||||
          total={restoreProgress.data.total}
 | 
			
		||||
          downloaded={restoreProgress.data.downloaded}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      {restoreState.status === AsyncStatus.Error && (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          <b>{restoreState.error.message}</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
    </InfoCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function AutoRestoreBackupOnVerification() {
 | 
			
		||||
  useRestoreBackupOnVerification();
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -19,7 +19,7 @@ export function CapabilitiesAndMediaConfigLoader({
 | 
			
		|||
    []
 | 
			
		||||
  >(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      const result = await Promise.allSettled([mx.getCapabilities(true), mx.getMediaConfig()]);
 | 
			
		||||
      const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
 | 
			
		||||
      const capabilities = promiseFulfilledResult(result[0]);
 | 
			
		||||
      const mediaConfig = promiseFulfilledResult(result[1]);
 | 
			
		||||
      return [capabilities, mediaConfig];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ type CapabilitiesLoaderProps = {
 | 
			
		|||
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx]));
 | 
			
		||||
  const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    load();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										318
									
								
								src/app/components/DeviceVerification.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										318
									
								
								src/app/components/DeviceVerification.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,318 @@
 | 
			
		|||
import {
 | 
			
		||||
  ShowSasCallbacks,
 | 
			
		||||
  VerificationPhase,
 | 
			
		||||
  VerificationRequest,
 | 
			
		||||
  Verifier,
 | 
			
		||||
} from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  config,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Header,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import {
 | 
			
		||||
  useVerificationRequestPhase,
 | 
			
		||||
  useVerificationRequestReceived,
 | 
			
		||||
  useVerifierCancel,
 | 
			
		||||
  useVerifierShowSas,
 | 
			
		||||
} from '../hooks/useVerificationRequest';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { ContainerColor } from '../styles/ContainerColor.css';
 | 
			
		||||
 | 
			
		||||
const DialogHeaderStyles: CSSProperties = {
 | 
			
		||||
  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WaitingMessageProps = {
 | 
			
		||||
  message: string;
 | 
			
		||||
};
 | 
			
		||||
function WaitingMessage({ message }: WaitingMessageProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box alignItems="Center" gap="200">
 | 
			
		||||
      <Spinner variant="Secondary" size="200" />
 | 
			
		||||
      <Text size="T300">{message}</Text>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type VerificationUnexpectedProps = { message: string; onClose: () => void };
 | 
			
		||||
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Text>{message}</Text>
 | 
			
		||||
      <Button variant="Secondary" fill="Soft" onClick={onClose}>
 | 
			
		||||
        <Text size="B400">Close</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function VerificationWaitAccept() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Text>Please accept the request from other device.</Text>
 | 
			
		||||
      <WaitingMessage message="Waiting for request to be accepted..." />
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type VerificationAcceptProps = {
 | 
			
		||||
  onAccept: () => Promise<void>;
 | 
			
		||||
};
 | 
			
		||||
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
 | 
			
		||||
  const [acceptState, accept] = useAsyncCallback(onAccept);
 | 
			
		||||
 | 
			
		||||
  const accepting = acceptState.status === AsyncStatus.Loading;
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Text>Click accept to start the verification process.</Text>
 | 
			
		||||
      <Button
 | 
			
		||||
        variant="Primary"
 | 
			
		||||
        fill="Solid"
 | 
			
		||||
        onClick={accept}
 | 
			
		||||
        before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
 | 
			
		||||
        disabled={accepting}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="B400">Accept</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function VerificationWaitStart() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Text>Verification request has been accepted.</Text>
 | 
			
		||||
      <WaitingMessage message="Waiting for the response from other device..." />
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type VerificationStartProps = {
 | 
			
		||||
  onStart: () => Promise<void>;
 | 
			
		||||
};
 | 
			
		||||
function AutoVerificationStart({ onStart }: VerificationStartProps) {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    onStart();
 | 
			
		||||
  }, [onStart]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <WaitingMessage message="Starting verification using emoji comparison..." />
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
 | 
			
		||||
  const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
 | 
			
		||||
 | 
			
		||||
  const confirming =
 | 
			
		||||
    confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
 | 
			
		||||
      <Box
 | 
			
		||||
        className={ContainerColor({ variant: 'SurfaceVariant' })}
 | 
			
		||||
        style={{
 | 
			
		||||
          borderRadius: config.radii.R400,
 | 
			
		||||
          padding: config.space.S500,
 | 
			
		||||
        }}
 | 
			
		||||
        gap="700"
 | 
			
		||||
        wrap="Wrap"
 | 
			
		||||
        justifyContent="Center"
 | 
			
		||||
      >
 | 
			
		||||
        {sasData.sas.emoji?.map(([emoji, name], index) => (
 | 
			
		||||
          <Box
 | 
			
		||||
            // eslint-disable-next-line react/no-array-index-key
 | 
			
		||||
            key={`${emoji}${name}${index}`}
 | 
			
		||||
            direction="Column"
 | 
			
		||||
            gap="100"
 | 
			
		||||
            justifyContent="Center"
 | 
			
		||||
            alignItems="Center"
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="H1">{emoji}</Text>
 | 
			
		||||
            <Text size="T200">{name}</Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        ))}
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box direction="Column" gap="200">
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="Primary"
 | 
			
		||||
          fill="Soft"
 | 
			
		||||
          onClick={confirm}
 | 
			
		||||
          disabled={confirming}
 | 
			
		||||
          before={confirming && <Spinner size="100" variant="Primary" />}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B400">They Match</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="Primary"
 | 
			
		||||
          fill="Soft"
 | 
			
		||||
          onClick={() => sasData.mismatch()}
 | 
			
		||||
          disabled={confirming}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B400">Do not Match</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type SasVerificationProps = {
 | 
			
		||||
  verifier: Verifier;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
};
 | 
			
		||||
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
 | 
			
		||||
  const [sasData, setSasData] = useState<ShowSasCallbacks>();
 | 
			
		||||
 | 
			
		||||
  useVerifierShowSas(verifier, setSasData);
 | 
			
		||||
  useVerifierCancel(verifier, onCancel);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    verifier.verify();
 | 
			
		||||
  }, [verifier]);
 | 
			
		||||
 | 
			
		||||
  if (sasData) {
 | 
			
		||||
    return <CompareEmoji sasData={sasData} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <WaitingMessage message="Starting verification using emoji comparison..." />
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type VerificationDoneProps = {
 | 
			
		||||
  onExit: () => void;
 | 
			
		||||
};
 | 
			
		||||
function VerificationDone({ onExit }: VerificationDoneProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <div>
 | 
			
		||||
        <Text>Your device is verified.</Text>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Button variant="Primary" fill="Solid" onClick={onExit}>
 | 
			
		||||
        <Text size="B400">Okay</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type VerificationCanceledProps = {
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Text>Verification has been canceled.</Text>
 | 
			
		||||
      <Button variant="Secondary" fill="Soft" onClick={onClose}>
 | 
			
		||||
        <Text size="B400">Close</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DeviceVerificationProps = {
 | 
			
		||||
  request: VerificationRequest;
 | 
			
		||||
  onExit: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
 | 
			
		||||
  const phase = useVerificationRequestPhase(request);
 | 
			
		||||
 | 
			
		||||
  const handleCancel = useCallback(() => {
 | 
			
		||||
    if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
 | 
			
		||||
      request.cancel();
 | 
			
		||||
    }
 | 
			
		||||
    onExit();
 | 
			
		||||
  }, [request, onExit]);
 | 
			
		||||
 | 
			
		||||
  const handleAccept = useCallback(() => request.accept(), [request]);
 | 
			
		||||
  const handleStart = useCallback(async () => {
 | 
			
		||||
    await request.startVerification(VerificationMethod.Sas);
 | 
			
		||||
  }, [request]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
      <OverlayCenter>
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            clickOutsideDeactivates: false,
 | 
			
		||||
            escapeDeactivates: false,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Dialog variant="Surface">
 | 
			
		||||
            <Header style={DialogHeaderStyles} variant="Surface" size="500">
 | 
			
		||||
              <Box grow="Yes">
 | 
			
		||||
                <Text size="H4">Device Verification</Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <IconButton size="300" radii="300" onClick={handleCancel}>
 | 
			
		||||
                <Icon src={Icons.Cross} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </Header>
 | 
			
		||||
            <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
              {phase === VerificationPhase.Requested &&
 | 
			
		||||
                (request.initiatedByMe ? (
 | 
			
		||||
                  <VerificationWaitAccept />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <VerificationAccept onAccept={handleAccept} />
 | 
			
		||||
                ))}
 | 
			
		||||
              {phase === VerificationPhase.Ready &&
 | 
			
		||||
                (request.initiatedByMe ? (
 | 
			
		||||
                  <AutoVerificationStart onStart={handleStart} />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <VerificationWaitStart />
 | 
			
		||||
                ))}
 | 
			
		||||
              {phase === VerificationPhase.Started &&
 | 
			
		||||
                (request.verifier ? (
 | 
			
		||||
                  <SasVerification verifier={request.verifier} onCancel={handleCancel} />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <VerificationUnexpected
 | 
			
		||||
                    message="Unexpected Error! Verification is started but verifier is missing."
 | 
			
		||||
                    onClose={handleCancel}
 | 
			
		||||
                  />
 | 
			
		||||
                ))}
 | 
			
		||||
              {phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
 | 
			
		||||
              {phase === VerificationPhase.Cancelled && (
 | 
			
		||||
                <VerificationCanceled onClose={handleCancel} />
 | 
			
		||||
              )}
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Dialog>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      </OverlayCenter>
 | 
			
		||||
    </Overlay>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ReceiveSelfDeviceVerification() {
 | 
			
		||||
  const [request, setRequest] = useState<VerificationRequest>();
 | 
			
		||||
 | 
			
		||||
  useVerificationRequestReceived(setRequest);
 | 
			
		||||
 | 
			
		||||
  const handleExit = useCallback(() => {
 | 
			
		||||
    setRequest(undefined);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  if (!request) return null;
 | 
			
		||||
 | 
			
		||||
  if (!request.isSelfVerification) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return <DeviceVerification request={request} onExit={handleExit} />;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										375
									
								
								src/app/components/DeviceVerificationSetup.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										375
									
								
								src/app/components/DeviceVerificationSetup.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,375 @@
 | 
			
		|||
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Header,
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  config,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  color,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FileSaver from 'file-saver';
 | 
			
		||||
import to from 'await-to-js';
 | 
			
		||||
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
 | 
			
		||||
import { PasswordInput } from './password-input';
 | 
			
		||||
import { ContainerColor } from '../styles/ContainerColor.css';
 | 
			
		||||
import { copyToClipboard } from '../utils/dom';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { clearSecretStorageKeys } from '../../client/state/secretStorageKeys';
 | 
			
		||||
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { useAlive } from '../hooks/useAlive';
 | 
			
		||||
import { UseStateProvider } from './UseStateProvider';
 | 
			
		||||
 | 
			
		||||
type UIACallback<T> = (
 | 
			
		||||
  authDict: AuthDict | null
 | 
			
		||||
) => Promise<[IAuthData, undefined] | [undefined, T]>;
 | 
			
		||||
 | 
			
		||||
type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>;
 | 
			
		||||
 | 
			
		||||
type UIAAction<T> = {
 | 
			
		||||
  authData: IAuthData;
 | 
			
		||||
  callback: UIACallback<T>;
 | 
			
		||||
  cancelCallback: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function makeUIAAction<T>(
 | 
			
		||||
  authData: IAuthData,
 | 
			
		||||
  performAction: PerformAction<T>,
 | 
			
		||||
  resolve: (data: T) => void,
 | 
			
		||||
  reject: (error?: any) => void
 | 
			
		||||
): UIAAction<T> {
 | 
			
		||||
  const action: UIAAction<T> = {
 | 
			
		||||
    authData,
 | 
			
		||||
    callback: async (authDict) => {
 | 
			
		||||
      const [error, data] = await to<T, MatrixError | Error>(performAction(authDict));
 | 
			
		||||
 | 
			
		||||
      if (error instanceof MatrixError && error.httpStatus === 401) {
 | 
			
		||||
        return [error.data as IAuthData, undefined];
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (error) {
 | 
			
		||||
        reject(error);
 | 
			
		||||
        throw error;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      resolve(data);
 | 
			
		||||
      return [undefined, data];
 | 
			
		||||
    },
 | 
			
		||||
    cancelCallback: reject,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return action;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type SetupVerificationProps = {
 | 
			
		||||
  onComplete: (recoveryKey: string) => void;
 | 
			
		||||
};
 | 
			
		||||
function SetupVerification({ onComplete }: SetupVerificationProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const [uiaAction, setUIAAction] = useState<UIAAction<void>>();
 | 
			
		||||
  const [nextAuthData, setNextAuthData] = useState<IAuthData | null>(); // null means no next action.
 | 
			
		||||
 | 
			
		||||
  const handleAction = useCallback(
 | 
			
		||||
    async (authDict: AuthDict) => {
 | 
			
		||||
      if (!uiaAction) {
 | 
			
		||||
        throw new Error('Unexpected Error! UIA action is perform without data.');
 | 
			
		||||
      }
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        setNextAuthData(null);
 | 
			
		||||
      }
 | 
			
		||||
      const [authData] = await uiaAction.callback(authDict);
 | 
			
		||||
 | 
			
		||||
      if (alive() && authData) {
 | 
			
		||||
        setNextAuthData(authData);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [uiaAction, alive]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const resetUIA = useCallback(() => {
 | 
			
		||||
    if (!alive()) return;
 | 
			
		||||
    setUIAAction(undefined);
 | 
			
		||||
    setNextAuthData(undefined);
 | 
			
		||||
  }, [alive]);
 | 
			
		||||
 | 
			
		||||
  const authUploadDeviceSigningKeys: UIAuthCallback<void> = useCallback(
 | 
			
		||||
    (makeRequest) =>
 | 
			
		||||
      new Promise<void>((resolve, reject) => {
 | 
			
		||||
        makeRequest(null)
 | 
			
		||||
          .then(() => {
 | 
			
		||||
            resolve();
 | 
			
		||||
            resetUIA();
 | 
			
		||||
          })
 | 
			
		||||
          .catch((error) => {
 | 
			
		||||
            if (error instanceof MatrixError && error.httpStatus === 401) {
 | 
			
		||||
              const authData = error.data as IAuthData;
 | 
			
		||||
              const action = makeUIAAction(
 | 
			
		||||
                authData,
 | 
			
		||||
                makeRequest as PerformAction<void>,
 | 
			
		||||
                resolve,
 | 
			
		||||
                (err) => {
 | 
			
		||||
                  resetUIA();
 | 
			
		||||
                  reject(err);
 | 
			
		||||
                }
 | 
			
		||||
              );
 | 
			
		||||
              if (alive()) {
 | 
			
		||||
                setUIAAction(action);
 | 
			
		||||
              } else {
 | 
			
		||||
                reject(new Error('Authentication failed! Failed to setup device verification.'));
 | 
			
		||||
              }
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
            reject(error);
 | 
			
		||||
          });
 | 
			
		||||
      }),
 | 
			
		||||
    [alive, resetUIA]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (passphrase) => {
 | 
			
		||||
        const crypto = mx.getCrypto();
 | 
			
		||||
        if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
 | 
			
		||||
 | 
			
		||||
        const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase);
 | 
			
		||||
        if (!recoveryKeyData.encodedPrivateKey) {
 | 
			
		||||
          throw new Error('Unexpected Error! Failed to create recovery key.');
 | 
			
		||||
        }
 | 
			
		||||
        clearSecretStorageKeys();
 | 
			
		||||
 | 
			
		||||
        await crypto.bootstrapSecretStorage({
 | 
			
		||||
          createSecretStorageKey: async () => recoveryKeyData,
 | 
			
		||||
          setupNewSecretStorage: true,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await crypto.bootstrapCrossSigning({
 | 
			
		||||
          authUploadDeviceSigningKeys,
 | 
			
		||||
          setupNewCrossSigning: true,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await crypto.resetKeyBackup();
 | 
			
		||||
 | 
			
		||||
        onComplete(recoveryKeyData.encodedPrivateKey);
 | 
			
		||||
      },
 | 
			
		||||
      [mx, onComplete, authUploadDeviceSigningKeys]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const loading = setupState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (loading) return;
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const passphraseInput = target?.passphraseInput as HTMLInputElement | undefined;
 | 
			
		||||
    let passphrase: string | undefined;
 | 
			
		||||
    if (passphraseInput && passphraseInput.value.length > 0) {
 | 
			
		||||
      passphrase = passphraseInput.value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setup(passphrase);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
 | 
			
		||||
      <Text size="T300">
 | 
			
		||||
        Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
 | 
			
		||||
        devices. Additionally, setup a passphrase as a memorable alternative.
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Box direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Passphrase (Optional)</Text>
 | 
			
		||||
        <PasswordInput name="passphraseInput" size="400" readOnly={loading} />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Button
 | 
			
		||||
        type="submit"
 | 
			
		||||
        disabled={loading}
 | 
			
		||||
        before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="B400">Continue</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
      {setupState.status === AsyncStatus.Error && (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          <b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
      {nextAuthData !== null && uiaAction && (
 | 
			
		||||
        <ActionUIAFlowsLoader
 | 
			
		||||
          authData={nextAuthData ?? uiaAction.authData}
 | 
			
		||||
          unsupported={() => (
 | 
			
		||||
            <Text size="T200">
 | 
			
		||||
              Authentication steps to perform this action are not supported by client.
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {(ongoingFlow) => (
 | 
			
		||||
            <ActionUIA
 | 
			
		||||
              authData={nextAuthData ?? uiaAction.authData}
 | 
			
		||||
              ongoingFlow={ongoingFlow}
 | 
			
		||||
              action={handleAction}
 | 
			
		||||
              onCancel={uiaAction.cancelCallback}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </ActionUIAFlowsLoader>
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RecoveryKeyDisplayProps = {
 | 
			
		||||
  recoveryKey: string;
 | 
			
		||||
};
 | 
			
		||||
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
 | 
			
		||||
  const [show, setShow] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleCopy = () => {
 | 
			
		||||
    copyToClipboard(recoveryKey);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDownload = () => {
 | 
			
		||||
    const blob = new Blob([recoveryKey], {
 | 
			
		||||
      type: 'text/plain;charset=us-ascii',
 | 
			
		||||
    });
 | 
			
		||||
    FileSaver.saveAs(blob, 'recovery-key.txt');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Text size="T300">
 | 
			
		||||
        Store the Recovery Key in a safe place for future use, as you will need it to verify your
 | 
			
		||||
        identity if you do not have access to other devices.
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Box direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Recovery Key</Text>
 | 
			
		||||
        <Box
 | 
			
		||||
          className={ContainerColor({ variant: 'SurfaceVariant' })}
 | 
			
		||||
          style={{
 | 
			
		||||
            padding: config.space.S300,
 | 
			
		||||
            borderRadius: config.radii.R400,
 | 
			
		||||
          }}
 | 
			
		||||
          alignItems="Center"
 | 
			
		||||
          justifyContent="Center"
 | 
			
		||||
          gap="400"
 | 
			
		||||
        >
 | 
			
		||||
          <Text style={{ fontFamily: 'monospace' }} size="T200" priority="300">
 | 
			
		||||
            {safeToDisplayKey}
 | 
			
		||||
          </Text>
 | 
			
		||||
          <Chip onClick={() => setShow(!show)} variant="Secondary" radii="Pill">
 | 
			
		||||
            <Text size="B300">{show ? 'Hide' : 'Show'}</Text>
 | 
			
		||||
          </Chip>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box direction="Column" gap="200">
 | 
			
		||||
        <Button onClick={handleCopy}>
 | 
			
		||||
          <Text size="B400">Copy</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button onClick={handleDownload} fill="Soft">
 | 
			
		||||
          <Text size="B400">Download</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DeviceVerificationSetupProps = {
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
};
 | 
			
		||||
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
 | 
			
		||||
  ({ onCancel }, ref) => {
 | 
			
		||||
    const [recoveryKey, setRecoveryKey] = useState<string>();
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Dialog ref={ref}>
 | 
			
		||||
        <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">Setup Device Verification</Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <IconButton size="300" radii="300" onClick={onCancel}>
 | 
			
		||||
            <Icon src={Icons.Cross} />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Header>
 | 
			
		||||
        <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
          {recoveryKey ? (
 | 
			
		||||
            <RecoveryKeyDisplay recoveryKey={recoveryKey} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <SetupVerification onComplete={setRecoveryKey} />
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
type DeviceVerificationResetProps = {
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
};
 | 
			
		||||
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
 | 
			
		||||
  ({ onCancel }, ref) => {
 | 
			
		||||
    const [reset, setReset] = useState(false);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Dialog ref={ref}>
 | 
			
		||||
        <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">Reset Device Verification</Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <IconButton size="300" radii="300" onClick={onCancel}>
 | 
			
		||||
            <Icon src={Icons.Cross} />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Header>
 | 
			
		||||
        {reset ? (
 | 
			
		||||
          <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
            <UseStateProvider initial={undefined}>
 | 
			
		||||
              {(recoveryKey: string | undefined, setRecoveryKey) =>
 | 
			
		||||
                recoveryKey ? (
 | 
			
		||||
                  <RecoveryKeyDisplay recoveryKey={recoveryKey} />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <SetupVerification onComplete={setRecoveryKey} />
 | 
			
		||||
                )
 | 
			
		||||
              }
 | 
			
		||||
            </UseStateProvider>
 | 
			
		||||
          </Box>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
            <Box direction="Column" gap="200">
 | 
			
		||||
              <Text size="H1">✋🧑🚒🤚</Text>
 | 
			
		||||
              <Text size="T300">Resetting device verification is permanent.</Text>
 | 
			
		||||
              <Text size="T300">
 | 
			
		||||
                Anyone you have verified with will see security alerts and your encryption backup
 | 
			
		||||
                will be lost. You almost certainly do not want to do this, unless you have lost{' '}
 | 
			
		||||
                <b>Recovery Key</b> or <b>Recovery Passphrase</b> and every device you can verify
 | 
			
		||||
                from.
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
            <Button variant="Critical" onClick={() => setReset(true)}>
 | 
			
		||||
              <Text size="B400">Reset</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										24
									
								
								src/app/components/DeviceVerificationStatus.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/app/components/DeviceVerificationStatus.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import { ReactNode } from 'react';
 | 
			
		||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import {
 | 
			
		||||
  useDeviceVerificationStatus,
 | 
			
		||||
  VerificationStatus,
 | 
			
		||||
} from '../hooks/useDeviceVerificationStatus';
 | 
			
		||||
 | 
			
		||||
type DeviceVerificationStatusProps = {
 | 
			
		||||
  crypto?: CryptoApi;
 | 
			
		||||
  userId: string;
 | 
			
		||||
  deviceId: string;
 | 
			
		||||
  children: (verificationStatus: VerificationStatus) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function DeviceVerificationStatus({
 | 
			
		||||
  crypto,
 | 
			
		||||
  userId,
 | 
			
		||||
  deviceId,
 | 
			
		||||
  children,
 | 
			
		||||
}: DeviceVerificationStatusProps) {
 | 
			
		||||
  const status = useDeviceVerificationStatus(crypto, userId, deviceId);
 | 
			
		||||
 | 
			
		||||
  return children(status);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										89
									
								
								src/app/components/LogoutDialog.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/app/components/LogoutDialog.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
import React, { forwardRef, useCallback } from 'react';
 | 
			
		||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { logoutClient } from '../../client/initMatrix';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
 | 
			
		||||
import { InfoCard } from './info-card';
 | 
			
		||||
import {
 | 
			
		||||
  useDeviceVerificationStatus,
 | 
			
		||||
  VerificationStatus,
 | 
			
		||||
} from '../hooks/useDeviceVerificationStatus';
 | 
			
		||||
 | 
			
		||||
type LogoutDialogProps = {
 | 
			
		||||
  handleClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
 | 
			
		||||
  ({ handleClose }, ref) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
 | 
			
		||||
    const crossSigningActive = useCrossSigningActive();
 | 
			
		||||
    const verificationStatus = useDeviceVerificationStatus(
 | 
			
		||||
      mx.getCrypto(),
 | 
			
		||||
      mx.getSafeUserId(),
 | 
			
		||||
      mx.getDeviceId() ?? undefined
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const [logoutState, logout] = useAsyncCallback<void, Error, []>(
 | 
			
		||||
      useCallback(async () => {
 | 
			
		||||
        await logoutClient(mx);
 | 
			
		||||
      }, [mx])
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const ongoingLogout = logoutState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Dialog variant="Surface" ref={ref}>
 | 
			
		||||
        <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">Logout</Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Header>
 | 
			
		||||
        <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
          {hasEncryptedRoom &&
 | 
			
		||||
            (crossSigningActive ? (
 | 
			
		||||
              verificationStatus === VerificationStatus.Unverified && (
 | 
			
		||||
                <InfoCard
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  title="Unverified Device"
 | 
			
		||||
                  description="Verify your device before logging out to save your encrypted messages."
 | 
			
		||||
                />
 | 
			
		||||
              )
 | 
			
		||||
            ) : (
 | 
			
		||||
              <InfoCard
 | 
			
		||||
                variant="Critical"
 | 
			
		||||
                title="Alert"
 | 
			
		||||
                description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          <Text priority="400">You’re about to log out. Are you sure?</Text>
 | 
			
		||||
          {logoutState.status === AsyncStatus.Error && (
 | 
			
		||||
            <Text style={{ color: color.Critical.Main }} size="T300">
 | 
			
		||||
              Failed to logout! {logoutState.error.message}
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
          <Box direction="Column" gap="200">
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              onClick={logout}
 | 
			
		||||
              disabled={ongoingLogout}
 | 
			
		||||
              before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B400">Logout</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
            <Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
 | 
			
		||||
              <Text size="B400">Cancel</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										199
									
								
								src/app/components/ManualVerification.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								src/app/components/ManualVerification.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,199 @@
 | 
			
		|||
import React, { MouseEventHandler, ReactNode, useCallback, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  Chip,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  Menu,
 | 
			
		||||
  config,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
  color,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { stopPropagation } from '../utils/keyboard';
 | 
			
		||||
import { SettingTile } from './setting-tile';
 | 
			
		||||
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
 | 
			
		||||
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { storePrivateKey } from '../../client/state/secretStorageKeys';
 | 
			
		||||
 | 
			
		||||
export enum ManualVerificationMethod {
 | 
			
		||||
  RecoveryPassphrase = 'passphrase',
 | 
			
		||||
  RecoveryKey = 'key',
 | 
			
		||||
}
 | 
			
		||||
type ManualVerificationMethodSwitcherProps = {
 | 
			
		||||
  value: ManualVerificationMethod;
 | 
			
		||||
  onChange: (value: ManualVerificationMethod) => void;
 | 
			
		||||
};
 | 
			
		||||
export function ManualVerificationMethodSwitcher({
 | 
			
		||||
  value,
 | 
			
		||||
  onChange,
 | 
			
		||||
}: ManualVerificationMethodSwitcherProps) {
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (method: ManualVerificationMethod) => {
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
    onChange(method);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Chip
 | 
			
		||||
        type="button"
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        radii="Pill"
 | 
			
		||||
        before={<Icon size="100" src={Icons.ChevronBottom} />}
 | 
			
		||||
        onClick={handleMenu}
 | 
			
		||||
      >
 | 
			
		||||
        <Text as="span" size="B300">
 | 
			
		||||
          {value === ManualVerificationMethod.RecoveryPassphrase && 'Recovery Passphrase'}
 | 
			
		||||
          {value === ManualVerificationMethod.RecoveryKey && 'Recovery Key'}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Chip>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Surface"
 | 
			
		||||
                  aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
 | 
			
		||||
                >
 | 
			
		||||
                  <Box grow="Yes">
 | 
			
		||||
                    <Text size="T300">Recovery Passphrase</Text>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </MenuItem>
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Surface"
 | 
			
		||||
                  aria-selected={value === ManualVerificationMethod.RecoveryKey}
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
 | 
			
		||||
                >
 | 
			
		||||
                  <Box grow="Yes">
 | 
			
		||||
                    <Text size="T300">Recovery Key</Text>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </MenuItem>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Menu>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ManualVerificationTileProps = {
 | 
			
		||||
  secretStorageKeyId: string;
 | 
			
		||||
  secretStorageKeyContent: SecretStorageKeyContent;
 | 
			
		||||
  options?: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function ManualVerificationTile({
 | 
			
		||||
  secretStorageKeyId,
 | 
			
		||||
  secretStorageKeyContent,
 | 
			
		||||
  options,
 | 
			
		||||
}: ManualVerificationTileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const hasPassphrase = !!secretStorageKeyContent.passphrase;
 | 
			
		||||
  const [method, setMethod] = useState(
 | 
			
		||||
    hasPassphrase
 | 
			
		||||
      ? ManualVerificationMethod.RecoveryPassphrase
 | 
			
		||||
      : ManualVerificationMethod.RecoveryKey
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const verifyAndRestoreBackup = useCallback(
 | 
			
		||||
    async (recoveryKey: Uint8Array) => {
 | 
			
		||||
      const crypto = mx.getCrypto();
 | 
			
		||||
      if (!crypto) {
 | 
			
		||||
        throw new Error('Unexpected Error! Crypto object not found.');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      storePrivateKey(secretStorageKeyId, recoveryKey);
 | 
			
		||||
 | 
			
		||||
      await crypto.bootstrapCrossSigning({});
 | 
			
		||||
      await crypto.bootstrapSecretStorage({});
 | 
			
		||||
 | 
			
		||||
      await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
 | 
			
		||||
    },
 | 
			
		||||
    [mx, secretStorageKeyId]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<void, Error, [Uint8Array]>(
 | 
			
		||||
    verifyAndRestoreBackup
 | 
			
		||||
  );
 | 
			
		||||
  const verifying = verifyState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="200">
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Verify Manually"
 | 
			
		||||
        description={hasPassphrase ? 'Select a verification method.' : 'Provide recovery key.'}
 | 
			
		||||
        after={
 | 
			
		||||
          <Box alignItems="Center" gap="200">
 | 
			
		||||
            {hasPassphrase && (
 | 
			
		||||
              <ManualVerificationMethodSwitcher value={method} onChange={setMethod} />
 | 
			
		||||
            )}
 | 
			
		||||
            {options}
 | 
			
		||||
          </Box>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {verifyState.status === AsyncStatus.Success ? (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Success.Main }}>
 | 
			
		||||
          <b>Device verified!</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Box direction="Column" gap="100">
 | 
			
		||||
          {method === ManualVerificationMethod.RecoveryKey && (
 | 
			
		||||
            <SecretStorageRecoveryKey
 | 
			
		||||
              processing={verifying}
 | 
			
		||||
              keyContent={secretStorageKeyContent}
 | 
			
		||||
              onDecodedRecoveryKey={handleDecodedRecoveryKey}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {method === ManualVerificationMethod.RecoveryPassphrase &&
 | 
			
		||||
            secretStorageKeyContent.passphrase && (
 | 
			
		||||
              <SecretStorageRecoveryPassphrase
 | 
			
		||||
                processing={verifying}
 | 
			
		||||
                keyContent={secretStorageKeyContent}
 | 
			
		||||
                passphraseContent={secretStorageKeyContent.passphrase}
 | 
			
		||||
                onDecodedRecoveryKey={handleDecodedRecoveryKey}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
          {verifyState.status === AsyncStatus.Error && (
 | 
			
		||||
            <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
              <b>{verifyState.error.message}</b>
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										29
									
								
								src/app/components/Modal500.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/app/components/Modal500.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
 | 
			
		||||
import { stopPropagation } from '../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
type Modal500Props = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function Modal500({ requestClose, children }: Modal500Props) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
      <OverlayCenter>
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
            onDeactivate: requestClose,
 | 
			
		||||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Modal size="500" variant="Background">
 | 
			
		||||
            {children}
 | 
			
		||||
          </Modal>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      </OverlayCenter>
 | 
			
		||||
    </Overlay>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										200
									
								
								src/app/components/SecretStorage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								src/app/components/SecretStorage.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,200 @@
 | 
			
		|||
import React, { FormEventHandler, useCallback } from 'react';
 | 
			
		||||
import { Box, Text, Button, Spinner, color } from 'folds';
 | 
			
		||||
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
 | 
			
		||||
import { PasswordInput } from './password-input';
 | 
			
		||||
import {
 | 
			
		||||
  SecretStorageKeyContent,
 | 
			
		||||
  SecretStoragePassphraseContent,
 | 
			
		||||
} from '../../types/matrix/accountData';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { useAlive } from '../hooks/useAlive';
 | 
			
		||||
 | 
			
		||||
type SecretStorageRecoveryPassphraseProps = {
 | 
			
		||||
  processing?: boolean;
 | 
			
		||||
  keyContent: SecretStorageKeyContent;
 | 
			
		||||
  passphraseContent: SecretStoragePassphraseContent;
 | 
			
		||||
  onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
 | 
			
		||||
};
 | 
			
		||||
export function SecretStorageRecoveryPassphrase({
 | 
			
		||||
  processing,
 | 
			
		||||
  keyContent,
 | 
			
		||||
  passphraseContent,
 | 
			
		||||
  onDecodedRecoveryKey,
 | 
			
		||||
}: SecretStorageRecoveryPassphraseProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const [driveKeyState, submitPassphrase] = useAsyncCallback<
 | 
			
		||||
    Uint8Array,
 | 
			
		||||
    Error,
 | 
			
		||||
    Parameters<typeof deriveKey>
 | 
			
		||||
  >(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (passphrase, salt, iterations, bits) => {
 | 
			
		||||
        const decodedRecoveryKey = await deriveKey(passphrase, salt, iterations, bits);
 | 
			
		||||
 | 
			
		||||
        const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
 | 
			
		||||
 | 
			
		||||
        if (!match) {
 | 
			
		||||
          throw new Error('Invalid recovery passphrase.');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return decodedRecoveryKey;
 | 
			
		||||
      },
 | 
			
		||||
      [mx, keyContent]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const drivingKey = driveKeyState.status === AsyncStatus.Loading;
 | 
			
		||||
  const loading = drivingKey || processing;
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    if (loading) return;
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const recoveryPassphraseInput = target?.recoveryPassphraseInput as HTMLInputElement | undefined;
 | 
			
		||||
    if (!recoveryPassphraseInput) return;
 | 
			
		||||
    const recoveryPassphrase = recoveryPassphraseInput.value.trim();
 | 
			
		||||
    if (!recoveryPassphrase) return;
 | 
			
		||||
 | 
			
		||||
    const { salt, iterations, bits } = passphraseContent;
 | 
			
		||||
    submitPassphrase(recoveryPassphrase, salt, iterations, bits).then((decodedRecoveryKey) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        recoveryPassphraseInput.value = '';
 | 
			
		||||
        onDecodedRecoveryKey(decodedRecoveryKey);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
 | 
			
		||||
      <Box gap="200" alignItems="End">
 | 
			
		||||
        <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
          <Text size="L400">Recovery Passphrase</Text>
 | 
			
		||||
          <PasswordInput
 | 
			
		||||
            name="recoveryPassphraseInput"
 | 
			
		||||
            size="400"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            autoFocus
 | 
			
		||||
            required
 | 
			
		||||
            outlined
 | 
			
		||||
            readOnly={loading}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No" gap="200">
 | 
			
		||||
          <Button
 | 
			
		||||
            type="submit"
 | 
			
		||||
            variant="Success"
 | 
			
		||||
            size="400"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
            before={loading && <Spinner size="200" variant="Success" fill="Solid" />}
 | 
			
		||||
          >
 | 
			
		||||
            <Text as="span" size="B400">
 | 
			
		||||
              Verify
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      {driveKeyState.status === AsyncStatus.Error && (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          <b>{driveKeyState.error.message}</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type SecretStorageRecoveryKeyProps = {
 | 
			
		||||
  processing?: boolean;
 | 
			
		||||
  keyContent: SecretStorageKeyContent;
 | 
			
		||||
  onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
 | 
			
		||||
};
 | 
			
		||||
export function SecretStorageRecoveryKey({
 | 
			
		||||
  processing,
 | 
			
		||||
  keyContent,
 | 
			
		||||
  onDecodedRecoveryKey,
 | 
			
		||||
}: SecretStorageRecoveryKeyProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const [driveKeyState, submitRecoveryKey] = useAsyncCallback<Uint8Array, Error, [string]>(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (recoveryKey) => {
 | 
			
		||||
        const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
 | 
			
		||||
 | 
			
		||||
        const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
 | 
			
		||||
 | 
			
		||||
        if (!match) {
 | 
			
		||||
          throw new Error('Invalid recovery key.');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return decodedRecoveryKey;
 | 
			
		||||
      },
 | 
			
		||||
      [mx, keyContent]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const drivingKey = driveKeyState.status === AsyncStatus.Loading;
 | 
			
		||||
  const loading = drivingKey || processing;
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const recoveryKeyInput = target?.recoveryKeyInput as HTMLInputElement | undefined;
 | 
			
		||||
    if (!recoveryKeyInput) return;
 | 
			
		||||
    const recoveryKey = recoveryKeyInput.value.trim();
 | 
			
		||||
    if (!recoveryKey) return;
 | 
			
		||||
 | 
			
		||||
    submitRecoveryKey(recoveryKey).then((decodedRecoveryKey) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        recoveryKeyInput.value = '';
 | 
			
		||||
        onDecodedRecoveryKey(decodedRecoveryKey);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
 | 
			
		||||
      <Box gap="200" alignItems="End">
 | 
			
		||||
        <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
          <Text size="L400">Recovery Key</Text>
 | 
			
		||||
          <PasswordInput
 | 
			
		||||
            name="recoveryKeyInput"
 | 
			
		||||
            size="400"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            autoFocus
 | 
			
		||||
            required
 | 
			
		||||
            outlined
 | 
			
		||||
            readOnly={loading}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No" gap="200">
 | 
			
		||||
          <Button
 | 
			
		||||
            type="submit"
 | 
			
		||||
            variant="Success"
 | 
			
		||||
            size="400"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
            before={loading && <Spinner size="200" variant="Success" fill="Solid" />}
 | 
			
		||||
          >
 | 
			
		||||
            <Text as="span" size="B400">
 | 
			
		||||
              Verify
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      {driveKeyState.status === AsyncStatus.Error && (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          <b>{driveKeyState.error.message}</b>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -13,7 +13,6 @@ import {
 | 
			
		|||
  IconButton,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { stopPropagation } from '../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
export type UIAFlowOverlayProps = {
 | 
			
		||||
  currentStep: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +28,7 @@ export function UIAFlowOverlay({
 | 
			
		|||
}: UIAFlowOverlayProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
      <FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: stopPropagation }}>
 | 
			
		||||
      <FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
 | 
			
		||||
        <Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
 | 
			
		||||
          <Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
 | 
			
		||||
            {children}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,14 +16,14 @@ import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'
 | 
			
		|||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
 | 
			
		||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
 | 
			
		||||
import { IEmoji, emojis } from '../../../plugins/emoji';
 | 
			
		||||
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
 | 
			
		||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
			
		||||
import { mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
 | 
			
		||||
 | 
			
		||||
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
 | 
			
		||||
 | 
			
		||||
type EmoticonSearchItem = ExtendedPackImage | IEmoji;
 | 
			
		||||
type EmoticonSearchItem = PackImageReader | IEmoji;
 | 
			
		||||
 | 
			
		||||
type EmoticonAutocompleteProps = {
 | 
			
		||||
  imagePackRooms: Room[];
 | 
			
		||||
| 
						 | 
				
			
			@ -52,21 +52,21 @@ export function EmoticonAutocomplete({
 | 
			
		|||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
 | 
			
		||||
  const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms);
 | 
			
		||||
  const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
 | 
			
		||||
  const recentEmoji = useRecentEmoji(mx, 20);
 | 
			
		||||
 | 
			
		||||
  const searchList = useMemo(() => {
 | 
			
		||||
    const list: Array<EmoticonSearchItem> = [];
 | 
			
		||||
    return list
 | 
			
		||||
      .concat(
 | 
			
		||||
        imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)),
 | 
			
		||||
    return list.concat(
 | 
			
		||||
      imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
 | 
			
		||||
      emojis
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
  }, [imagePacks]);
 | 
			
		||||
 | 
			
		||||
  const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
 | 
			
		||||
  const autoCompleteEmoticon = (result ? result.items : recentEmoji)
 | 
			
		||||
      .sort((a, b) => a.shortcode.localeCompare(b.shortcode));
 | 
			
		||||
  const autoCompleteEmoticon = (result ? result.items : recentEmoji).sort((a, b) =>
 | 
			
		||||
    a.shortcode.localeCompare(b.shortcode)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (query.text) search(query.text);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,7 +41,6 @@ import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard
 | 
			
		|||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
 | 
			
		||||
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
 | 
			
		||||
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
 | 
			
		||||
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
 | 
			
		||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
 | 
			
		||||
| 
						 | 
				
			
			@ -50,6 +49,7 @@ import { useThrottle } from '../../hooks/useThrottle';
 | 
			
		|||
import { addRecentEmoji } from '../../plugins/recent-emoji';
 | 
			
		||||
import { mobileOrTablet } from '../../utils/user-agent';
 | 
			
		||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
			
		||||
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
 | 
			
		||||
 | 
			
		||||
const RECENT_GROUP_ID = 'recent_group';
 | 
			
		||||
const SEARCH_GROUP_ID = 'search_group';
 | 
			
		||||
| 
						 | 
				
			
			@ -359,16 +359,16 @@ function ImagePackSidebarStack({
 | 
			
		|||
}: {
 | 
			
		||||
  mx: MatrixClient;
 | 
			
		||||
  packs: ImagePack[];
 | 
			
		||||
  usage: PackUsage;
 | 
			
		||||
  usage: ImageUsage;
 | 
			
		||||
  onItemClick: (id: string) => void;
 | 
			
		||||
  useAuthentication?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const activeGroupId = useAtomValue(activeGroupIdAtom);
 | 
			
		||||
  return (
 | 
			
		||||
    <SidebarStack>
 | 
			
		||||
      {usage === PackUsage.Emoticon && <SidebarDivider />}
 | 
			
		||||
      {usage === ImageUsage.Emoticon && <SidebarDivider />}
 | 
			
		||||
      {packs.map((pack) => {
 | 
			
		||||
        let label = pack.displayName;
 | 
			
		||||
        let label = pack.meta.name;
 | 
			
		||||
        if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
 | 
			
		||||
        return (
 | 
			
		||||
          <SidebarBtn
 | 
			
		||||
| 
						 | 
				
			
			@ -384,7 +384,10 @@ function ImagePackSidebarStack({
 | 
			
		|||
                height: toRem(24),
 | 
			
		||||
                objectFit: 'contain',
 | 
			
		||||
              }}
 | 
			
		||||
              src={mxcUrlToHttp(mx, pack.getPackAvatarUrl(usage) ?? '', useAuthentication) || pack.avatarUrl}
 | 
			
		||||
              src={
 | 
			
		||||
                mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ||
 | 
			
		||||
                pack.meta.avatar
 | 
			
		||||
              }
 | 
			
		||||
              alt={label || 'Unknown Pack'}
 | 
			
		||||
            />
 | 
			
		||||
          </SidebarBtn>
 | 
			
		||||
| 
						 | 
				
			
			@ -462,13 +465,15 @@ export function SearchEmojiGroup({
 | 
			
		|||
  tab: EmojiBoardTab;
 | 
			
		||||
  label: string;
 | 
			
		||||
  id: string;
 | 
			
		||||
  emojis: Array<ExtendedPackImage | IEmoji>;
 | 
			
		||||
  emojis: Array<PackImageReader | IEmoji>;
 | 
			
		||||
  useAuthentication?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <EmojiGroup key={id} id={id} label={label}>
 | 
			
		||||
      {tab === EmojiBoardTab.Emoji
 | 
			
		||||
        ? searchResult.sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((emoji) =>
 | 
			
		||||
        ? searchResult
 | 
			
		||||
            .sort((a, b) => a.shortcode.localeCompare(b.shortcode))
 | 
			
		||||
            .map((emoji) =>
 | 
			
		||||
              'unicode' in emoji ? (
 | 
			
		||||
                <EmojiItem
 | 
			
		||||
                  key={emoji.unicode}
 | 
			
		||||
| 
						 | 
				
			
			@ -519,11 +524,22 @@ export function SearchEmojiGroup({
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export const CustomEmojiGroups = memo(
 | 
			
		||||
  ({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => (
 | 
			
		||||
  ({
 | 
			
		||||
    mx,
 | 
			
		||||
    groups,
 | 
			
		||||
    useAuthentication,
 | 
			
		||||
  }: {
 | 
			
		||||
    mx: MatrixClient;
 | 
			
		||||
    groups: ImagePack[];
 | 
			
		||||
    useAuthentication?: boolean;
 | 
			
		||||
  }) => (
 | 
			
		||||
    <>
 | 
			
		||||
      {groups.map((pack) => (
 | 
			
		||||
        <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
 | 
			
		||||
          {pack.getEmojis().sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((image) => (
 | 
			
		||||
        <EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
 | 
			
		||||
          {pack
 | 
			
		||||
            .getImages(ImageUsage.Emoticon)
 | 
			
		||||
            .sort((a, b) => a.shortcode.localeCompare(b.shortcode))
 | 
			
		||||
            .map((image) => (
 | 
			
		||||
              <EmojiItem
 | 
			
		||||
                key={image.shortcode}
 | 
			
		||||
                label={image.body || image.shortcode}
 | 
			
		||||
| 
						 | 
				
			
			@ -545,7 +561,16 @@ export const CustomEmojiGroups = memo(
 | 
			
		|||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => (
 | 
			
		||||
export const StickerGroups = memo(
 | 
			
		||||
  ({
 | 
			
		||||
    mx,
 | 
			
		||||
    groups,
 | 
			
		||||
    useAuthentication,
 | 
			
		||||
  }: {
 | 
			
		||||
    mx: MatrixClient;
 | 
			
		||||
    groups: ImagePack[];
 | 
			
		||||
    useAuthentication?: boolean;
 | 
			
		||||
  }) => (
 | 
			
		||||
    <>
 | 
			
		||||
      {groups.length === 0 && (
 | 
			
		||||
        <Box
 | 
			
		||||
| 
						 | 
				
			
			@ -565,8 +590,11 @@ export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: Matr
 | 
			
		|||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
      {groups.map((pack) => (
 | 
			
		||||
      <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
 | 
			
		||||
        {pack.getStickers().sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((image) => (
 | 
			
		||||
        <EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
 | 
			
		||||
          {pack
 | 
			
		||||
            .getImages(ImageUsage.Sticker)
 | 
			
		||||
            .sort((a, b) => a.shortcode.localeCompare(b.shortcode))
 | 
			
		||||
            .map((image) => (
 | 
			
		||||
              <StickerItem
 | 
			
		||||
                key={image.shortcode}
 | 
			
		||||
                label={image.body || image.shortcode}
 | 
			
		||||
| 
						 | 
				
			
			@ -585,7 +613,8 @@ export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: Matr
 | 
			
		|||
        </EmojiGroup>
 | 
			
		||||
      ))}
 | 
			
		||||
    </>
 | 
			
		||||
));
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const NativeEmojiGroups = memo(
 | 
			
		||||
  ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
 | 
			
		||||
| 
						 | 
				
			
			@ -609,7 +638,7 @@ export const NativeEmojiGroups = memo(
 | 
			
		|||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => {
 | 
			
		||||
const getSearchListItemStr = (item: PackImageReader | IEmoji) => {
 | 
			
		||||
  const shortcode = `:${item.shortcode}:`;
 | 
			
		||||
  if ('body' in item) {
 | 
			
		||||
    return [shortcode, item.body ?? ''];
 | 
			
		||||
| 
						 | 
				
			
			@ -646,14 +675,14 @@ export function EmojiBoard({
 | 
			
		|||
}) {
 | 
			
		||||
  const emojiTab = tab === EmojiBoardTab.Emoji;
 | 
			
		||||
  const stickerTab = tab === EmojiBoardTab.Sticker;
 | 
			
		||||
  const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker;
 | 
			
		||||
  const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
 | 
			
		||||
 | 
			
		||||
  const setActiveGroupId = useSetAtom(activeGroupIdAtom);
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const emojiGroupLabels = useEmojiGroupLabels();
 | 
			
		||||
  const emojiGroupIcons = useEmojiGroupIcons();
 | 
			
		||||
  const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms);
 | 
			
		||||
  const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
 | 
			
		||||
  const recentEmojis = useRecentEmoji(mx, 21);
 | 
			
		||||
 | 
			
		||||
  const contentScrollRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
| 
						 | 
				
			
			@ -661,8 +690,8 @@ export function EmojiBoard({
 | 
			
		|||
  const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
 | 
			
		||||
 | 
			
		||||
  const searchList = useMemo(() => {
 | 
			
		||||
    let list: Array<ExtendedPackImage | IEmoji> = [];
 | 
			
		||||
    list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage)));
 | 
			
		||||
    let list: Array<PackImageReader | IEmoji> = [];
 | 
			
		||||
    list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
 | 
			
		||||
    if (emojiTab) list = list.concat(emojis);
 | 
			
		||||
    return list;
 | 
			
		||||
  }, [emojiTab, usage, imagePacks]);
 | 
			
		||||
| 
						 | 
				
			
			@ -688,7 +717,7 @@ export function EmojiBoard({
 | 
			
		|||
  const syncActiveGroupId = useCallback(() => {
 | 
			
		||||
    const targetEl = contentScrollRef.current;
 | 
			
		||||
    if (!targetEl) return;
 | 
			
		||||
    const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[];
 | 
			
		||||
    const groupEls = Array.from(targetEl.querySelectorAll('div[data-group-id]')) as HTMLElement[];
 | 
			
		||||
    const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
 | 
			
		||||
    const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
 | 
			
		||||
    setActiveGroupId(groupId);
 | 
			
		||||
| 
						 | 
				
			
			@ -735,7 +764,10 @@ export function EmojiBoard({
 | 
			
		|||
      } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
 | 
			
		||||
        const img = document.createElement('img');
 | 
			
		||||
        img.className = css.CustomEmojiImg;
 | 
			
		||||
        img.setAttribute('src', mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data);
 | 
			
		||||
        img.setAttribute(
 | 
			
		||||
          'src',
 | 
			
		||||
          mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data
 | 
			
		||||
        );
 | 
			
		||||
        img.setAttribute('alt', emojiInfo.shortcode);
 | 
			
		||||
        emojiPreviewRef.current.textContent = '';
 | 
			
		||||
        emojiPreviewRef.current.appendChild(img);
 | 
			
		||||
| 
						 | 
				
			
			@ -903,8 +935,16 @@ export function EmojiBoard({
 | 
			
		|||
              {emojiTab && recentEmojis.length > 0 && (
 | 
			
		||||
                <RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
 | 
			
		||||
              )}
 | 
			
		||||
              {emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />}
 | 
			
		||||
              {stickerTab && <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />}
 | 
			
		||||
              {emojiTab && (
 | 
			
		||||
                <CustomEmojiGroups
 | 
			
		||||
                  mx={mx}
 | 
			
		||||
                  groups={imagePacks}
 | 
			
		||||
                  useAuthentication={useAuthentication}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
              {stickerTab && (
 | 
			
		||||
                <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />
 | 
			
		||||
              )}
 | 
			
		||||
              {emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Scroll>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										35
									
								
								src/app/components/image-editor/ImageEditor.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/app/components/image-editor/ImageEditor.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { DefaultReset, color, config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const ImageEditor = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    height: '100%',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const ImageEditorHeader = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    paddingLeft: config.space.S200,
 | 
			
		||||
    paddingRight: config.space.S200,
 | 
			
		||||
    borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
    flexShrink: 0,
 | 
			
		||||
    gap: config.space.S200,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const ImageEditorContent = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    backgroundColor: color.Background.Container,
 | 
			
		||||
    color: color.Background.OnContainer,
 | 
			
		||||
    overflow: 'hidden',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const Image = style({
 | 
			
		||||
  width: '100%',
 | 
			
		||||
  height: '100%',
 | 
			
		||||
  objectFit: 'contain',
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										51
									
								
								src/app/components/image-editor/ImageEditor.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/app/components/image-editor/ImageEditor.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,51 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
 | 
			
		||||
import * as css from './ImageEditor.css';
 | 
			
		||||
 | 
			
		||||
export type ImageEditorProps = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ImageEditor = as<'div', ImageEditorProps>(
 | 
			
		||||
  ({ className, name, url, requestClose, ...props }, ref) => {
 | 
			
		||||
    const handleApply = () => {
 | 
			
		||||
      //
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Box
 | 
			
		||||
        className={classNames(css.ImageEditor, className)}
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        {...props}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
      >
 | 
			
		||||
        <Header className={css.ImageEditorHeader} size="400">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <IconButton size="300" radii="300" onClick={requestClose}>
 | 
			
		||||
              <Icon size="50" src={Icons.ArrowLeft} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
            <Text size="T300" truncate>
 | 
			
		||||
              Image Editor
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No" alignItems="Center" gap="200">
 | 
			
		||||
            <Chip variant="Primary" radii="300" onClick={handleApply}>
 | 
			
		||||
              <Text size="B300">Save</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Header>
 | 
			
		||||
        <Box
 | 
			
		||||
          grow="Yes"
 | 
			
		||||
          className={css.ImageEditorContent}
 | 
			
		||||
          justifyContent="Center"
 | 
			
		||||
          alignItems="Center"
 | 
			
		||||
        >
 | 
			
		||||
          <img className={css.Image} src={url} alt={name} />
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/image-editor/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/image-editor/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './ImageEditor';
 | 
			
		||||
							
								
								
									
										388
									
								
								src/app/components/image-pack-view/ImagePackContent.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										388
									
								
								src/app/components/image-pack-view/ImagePackContent.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,388 @@
 | 
			
		|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { as, Box, Text, config, Button, Menu, Spinner } from 'folds';
 | 
			
		||||
import {
 | 
			
		||||
  ImagePack,
 | 
			
		||||
  ImageUsage,
 | 
			
		||||
  PackContent,
 | 
			
		||||
  PackImage,
 | 
			
		||||
  PackImageReader,
 | 
			
		||||
  packMetaEqual,
 | 
			
		||||
  PackMetaReader,
 | 
			
		||||
} from '../../plugins/custom-emoji';
 | 
			
		||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
			
		||||
import { SequenceCard } from '../sequence-card';
 | 
			
		||||
import { ImageTile, ImageTileEdit, ImageTileUpload } from './ImageTile';
 | 
			
		||||
import { SettingTile } from '../setting-tile';
 | 
			
		||||
import { UsageSwitcher } from './UsageSwitcher';
 | 
			
		||||
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import { useFilePicker } from '../../hooks/useFilePicker';
 | 
			
		||||
import { CompactUploadCardRenderer } from '../upload-card';
 | 
			
		||||
import { UploadSuccess } from '../../state/upload';
 | 
			
		||||
import { getImageInfo, TUploadContent } from '../../utils/matrix';
 | 
			
		||||
import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom';
 | 
			
		||||
import { replaceSpaceWithDash, suffixRename } from '../../utils/common';
 | 
			
		||||
import { getFileNameWithoutExt } from '../../utils/mimeTypes';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
 | 
			
		||||
export type ImagePackContentProps = {
 | 
			
		||||
  imagePack: ImagePack;
 | 
			
		||||
  canEdit?: boolean;
 | 
			
		||||
  onUpdate?: (packContent: PackContent) => Promise<void>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ImagePackContent = as<'div', ImagePackContentProps>(
 | 
			
		||||
  ({ imagePack, canEdit, onUpdate, ...props }, ref) => {
 | 
			
		||||
    const useAuthentication = useMediaAuthentication();
 | 
			
		||||
 | 
			
		||||
    const [metaEditing, setMetaEditing] = useState(false);
 | 
			
		||||
    const [savedMeta, setSavedMeta] = useState<PackMetaReader>();
 | 
			
		||||
    const currentMeta = savedMeta ?? imagePack.meta;
 | 
			
		||||
 | 
			
		||||
    const images = useMemo(() => Array.from(imagePack.images.collection.values()), [imagePack]);
 | 
			
		||||
    const [files, setFiles] = useState<File[]>([]);
 | 
			
		||||
    const [uploadedImages, setUploadedImages] = useState<PackImageReader[]>([]);
 | 
			
		||||
    const [imagesEditing, setImagesEditing] = useState<Set<string>>(new Set());
 | 
			
		||||
    const [savedImages, setSavedImages] = useState<Map<string, PackImageReader>>(new Map());
 | 
			
		||||
    const [deleteImages, setDeleteImages] = useState<Set<string>>(new Set());
 | 
			
		||||
 | 
			
		||||
    const hasImageWithShortcode = useCallback(
 | 
			
		||||
      (shortcode: string): boolean => {
 | 
			
		||||
        const hasInPack = imagePack.images.collection.has(shortcode);
 | 
			
		||||
        if (hasInPack) return true;
 | 
			
		||||
        const hasInUploaded =
 | 
			
		||||
          uploadedImages.find((img) => img.shortcode === shortcode) !== undefined;
 | 
			
		||||
        if (hasInUploaded) return true;
 | 
			
		||||
        const hasInSaved =
 | 
			
		||||
          Array.from(savedImages).find(([, img]) => img.shortcode === shortcode) !== undefined;
 | 
			
		||||
        return hasInSaved;
 | 
			
		||||
      },
 | 
			
		||||
      [imagePack, savedImages, uploadedImages]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const pickFiles = useFilePicker(
 | 
			
		||||
      useCallback(
 | 
			
		||||
        (pickedFiles: File[]) => {
 | 
			
		||||
          const uniqueFiles = pickedFiles.map((file) => {
 | 
			
		||||
            const fileName = replaceSpaceWithDash(file.name);
 | 
			
		||||
            if (hasImageWithShortcode(fileName)) {
 | 
			
		||||
              const uniqueName = suffixRename(fileName, hasImageWithShortcode);
 | 
			
		||||
              return renameFile(file, uniqueName);
 | 
			
		||||
            }
 | 
			
		||||
            return fileName !== file.name ? renameFile(file, fileName) : file;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          setFiles((f) => [...f, ...uniqueFiles]);
 | 
			
		||||
        },
 | 
			
		||||
        [hasImageWithShortcode]
 | 
			
		||||
      ),
 | 
			
		||||
      true
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleMetaSave = useCallback(
 | 
			
		||||
      (editedMeta: PackMetaReader) => {
 | 
			
		||||
        setMetaEditing(false);
 | 
			
		||||
        setSavedMeta(
 | 
			
		||||
          (m) =>
 | 
			
		||||
            new PackMetaReader({
 | 
			
		||||
              ...imagePack.meta.content,
 | 
			
		||||
              ...m?.content,
 | 
			
		||||
              ...editedMeta.content,
 | 
			
		||||
            })
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
      [imagePack.meta]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleMetaCancel = () => setMetaEditing(false);
 | 
			
		||||
 | 
			
		||||
    const handlePackUsageChange = useCallback(
 | 
			
		||||
      (usg: ImageUsage[]) => {
 | 
			
		||||
        setSavedMeta(
 | 
			
		||||
          (m) =>
 | 
			
		||||
            new PackMetaReader({
 | 
			
		||||
              ...imagePack.meta.content,
 | 
			
		||||
              ...m?.content,
 | 
			
		||||
              usage: usg,
 | 
			
		||||
            })
 | 
			
		||||
        );
 | 
			
		||||
      },
 | 
			
		||||
      [imagePack.meta]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleUploadRemove = useCallback((file: TUploadContent) => {
 | 
			
		||||
      setFiles((fs) => fs.filter((f) => f !== file));
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const handleUploadComplete = useCallback(
 | 
			
		||||
      async (data: UploadSuccess) => {
 | 
			
		||||
        const imgEl = await loadImageElement(getImageFileUrl(data.file));
 | 
			
		||||
        const packImage: PackImage = {
 | 
			
		||||
          url: data.mxc,
 | 
			
		||||
          info: getImageInfo(imgEl, data.file),
 | 
			
		||||
        };
 | 
			
		||||
        const image = PackImageReader.fromPackImage(
 | 
			
		||||
          getFileNameWithoutExt(data.file.name),
 | 
			
		||||
          packImage
 | 
			
		||||
        );
 | 
			
		||||
        if (!image) return;
 | 
			
		||||
        handleUploadRemove(data.file);
 | 
			
		||||
        setUploadedImages((imgs) => [image, ...imgs]);
 | 
			
		||||
      },
 | 
			
		||||
      [handleUploadRemove]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleImageEdit = (shortcode: string) => {
 | 
			
		||||
      setImagesEditing((shortcodes) => {
 | 
			
		||||
        const shortcodeSet = new Set(shortcodes);
 | 
			
		||||
        shortcodeSet.add(shortcode);
 | 
			
		||||
        return shortcodeSet;
 | 
			
		||||
      });
 | 
			
		||||
    };
 | 
			
		||||
    const handleDeleteToggle = (shortcode: string) => {
 | 
			
		||||
      setDeleteImages((shortcodes) => {
 | 
			
		||||
        const shortcodeSet = new Set(shortcodes);
 | 
			
		||||
        if (shortcodeSet.has(shortcode)) shortcodeSet.delete(shortcode);
 | 
			
		||||
        else shortcodeSet.add(shortcode);
 | 
			
		||||
        return shortcodeSet;
 | 
			
		||||
      });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleImageEditCancel = (shortcode: string) => {
 | 
			
		||||
      setImagesEditing((shortcodes) => {
 | 
			
		||||
        const shortcodeSet = new Set(shortcodes);
 | 
			
		||||
        shortcodeSet.delete(shortcode);
 | 
			
		||||
        return shortcodeSet;
 | 
			
		||||
      });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleImageEditSave = (shortcode: string, image: PackImageReader) => {
 | 
			
		||||
      handleImageEditCancel(shortcode);
 | 
			
		||||
 | 
			
		||||
      const saveImage =
 | 
			
		||||
        shortcode !== image.shortcode && hasImageWithShortcode(image.shortcode)
 | 
			
		||||
          ? new PackImageReader(
 | 
			
		||||
              suffixRename(image.shortcode, hasImageWithShortcode),
 | 
			
		||||
              image.url,
 | 
			
		||||
              image.content
 | 
			
		||||
            )
 | 
			
		||||
          : image;
 | 
			
		||||
 | 
			
		||||
      setSavedImages((sImgs) => {
 | 
			
		||||
        const imgs = new Map(sImgs);
 | 
			
		||||
        imgs.set(shortcode, saveImage);
 | 
			
		||||
        return imgs;
 | 
			
		||||
      });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleResetSavedChanges = () => {
 | 
			
		||||
      setSavedMeta(undefined);
 | 
			
		||||
      setFiles([]);
 | 
			
		||||
      setUploadedImages([]);
 | 
			
		||||
      setSavedImages(new Map());
 | 
			
		||||
      setDeleteImages(new Set());
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const [applyState, applyChanges] = useAsyncCallback(
 | 
			
		||||
      useCallback(async () => {
 | 
			
		||||
        const pack: PackContent = {
 | 
			
		||||
          pack: savedMeta?.content ?? imagePack.meta.content,
 | 
			
		||||
          images: {},
 | 
			
		||||
        };
 | 
			
		||||
        const pushImage = (img: PackImageReader) => {
 | 
			
		||||
          if (deleteImages.has(img.shortcode)) return;
 | 
			
		||||
          if (!pack.images) return;
 | 
			
		||||
          const imgToPush = savedImages.get(img.shortcode) ?? img;
 | 
			
		||||
          pack.images[imgToPush.shortcode] = imgToPush.content;
 | 
			
		||||
        };
 | 
			
		||||
        uploadedImages.forEach((img) => pushImage(img));
 | 
			
		||||
        images.forEach((img) => pushImage(img));
 | 
			
		||||
 | 
			
		||||
        return onUpdate?.(pack);
 | 
			
		||||
      }, [imagePack, images, savedMeta, uploadedImages, savedImages, deleteImages, onUpdate])
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      if (applyState.status === AsyncStatus.Success) {
 | 
			
		||||
        handleResetSavedChanges();
 | 
			
		||||
      }
 | 
			
		||||
    }, [applyState]);
 | 
			
		||||
 | 
			
		||||
    const savedChanges =
 | 
			
		||||
      (savedMeta && !packMetaEqual(imagePack.meta, savedMeta)) ||
 | 
			
		||||
      uploadedImages.length > 0 ||
 | 
			
		||||
      savedImages.size > 0 ||
 | 
			
		||||
      deleteImages.size > 0;
 | 
			
		||||
    const canApplyChanges = !metaEditing && imagesEditing.size === 0 && files.length === 0;
 | 
			
		||||
    const applying = applyState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
    const renderImage = (image: PackImageReader) => (
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        key={image.shortcode}
 | 
			
		||||
        style={{ padding: config.space.S300 }}
 | 
			
		||||
        variant={deleteImages.has(image.shortcode) ? 'Critical' : 'SurfaceVariant'}
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        {imagesEditing.has(image.shortcode) ? (
 | 
			
		||||
          <ImageTileEdit
 | 
			
		||||
            defaultShortcode={image.shortcode}
 | 
			
		||||
            image={savedImages.get(image.shortcode) ?? image}
 | 
			
		||||
            packUsage={currentMeta.usage}
 | 
			
		||||
            useAuthentication={useAuthentication}
 | 
			
		||||
            onCancel={handleImageEditCancel}
 | 
			
		||||
            onSave={handleImageEditSave}
 | 
			
		||||
          />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <ImageTile
 | 
			
		||||
            defaultShortcode={image.shortcode}
 | 
			
		||||
            image={savedImages.get(image.shortcode) ?? image}
 | 
			
		||||
            packUsage={currentMeta.usage}
 | 
			
		||||
            useAuthentication={useAuthentication}
 | 
			
		||||
            canEdit={canEdit}
 | 
			
		||||
            onEdit={handleImageEdit}
 | 
			
		||||
            deleted={deleteImages.has(image.shortcode)}
 | 
			
		||||
            onDeleteToggle={handleDeleteToggle}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Box grow="Yes" direction="Column" gap="700" {...props} ref={ref}>
 | 
			
		||||
        {savedChanges && (
 | 
			
		||||
          <Menu className={css.UnsavedMenu} variant="Success">
 | 
			
		||||
            <Box alignItems="Center" gap="400">
 | 
			
		||||
              <Box grow="Yes" direction="Column">
 | 
			
		||||
                {applyState.status === AsyncStatus.Error ? (
 | 
			
		||||
                  <Text size="T200">
 | 
			
		||||
                    <b>Failed to apply changes! Please try again.</b>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <Text size="T200">
 | 
			
		||||
                    <b>Changes saved! Apply when ready.</b>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                )}
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box shrink="No" gap="200">
 | 
			
		||||
                <Button
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Success"
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  disabled={!canApplyChanges || applying}
 | 
			
		||||
                  onClick={handleResetSavedChanges}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">Reset</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
                <Button
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Success"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  disabled={!canApplyChanges || applying}
 | 
			
		||||
                  before={applying && <Spinner variant="Success" fill="Solid" size="100" />}
 | 
			
		||||
                  onClick={applyChanges}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">Apply Changes</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Menu>
 | 
			
		||||
        )}
 | 
			
		||||
        <Box direction="Column" gap="100">
 | 
			
		||||
          <Text size="L400">Pack</Text>
 | 
			
		||||
          <SequenceCard
 | 
			
		||||
            style={{ padding: config.space.S300 }}
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            direction="Column"
 | 
			
		||||
            gap="400"
 | 
			
		||||
          >
 | 
			
		||||
            {metaEditing ? (
 | 
			
		||||
              <ImagePackProfileEdit
 | 
			
		||||
                meta={currentMeta}
 | 
			
		||||
                onCancel={handleMetaCancel}
 | 
			
		||||
                onSave={handleMetaSave}
 | 
			
		||||
              />
 | 
			
		||||
            ) : (
 | 
			
		||||
              <ImagePackProfile
 | 
			
		||||
                meta={currentMeta}
 | 
			
		||||
                canEdit={canEdit}
 | 
			
		||||
                onEdit={() => setMetaEditing(true)}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
          </SequenceCard>
 | 
			
		||||
          <SequenceCard
 | 
			
		||||
            style={{ padding: config.space.S300 }}
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            direction="Column"
 | 
			
		||||
            gap="400"
 | 
			
		||||
          >
 | 
			
		||||
            <SettingTile
 | 
			
		||||
              title="Images Usage"
 | 
			
		||||
              description="Select how the images are being used: as emojis, as stickers, or as both."
 | 
			
		||||
              after={
 | 
			
		||||
                <UsageSwitcher
 | 
			
		||||
                  usage={currentMeta.usage}
 | 
			
		||||
                  canEdit={canEdit}
 | 
			
		||||
                  onChange={handlePackUsageChange}
 | 
			
		||||
                />
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </SequenceCard>
 | 
			
		||||
        </Box>
 | 
			
		||||
        {images.length === 0 && !canEdit ? null : (
 | 
			
		||||
          <Box direction="Column" gap="100">
 | 
			
		||||
            <Text size="L400">Images</Text>
 | 
			
		||||
            {canEdit && (
 | 
			
		||||
              <SequenceCard
 | 
			
		||||
                style={{ padding: config.space.S300 }}
 | 
			
		||||
                variant="SurfaceVariant"
 | 
			
		||||
                direction="Column"
 | 
			
		||||
                gap="400"
 | 
			
		||||
              >
 | 
			
		||||
                <SettingTile
 | 
			
		||||
                  title="Upload Images"
 | 
			
		||||
                  description="Select images from your storage to upload them in pack."
 | 
			
		||||
                  after={
 | 
			
		||||
                    <Button
 | 
			
		||||
                      variant="Secondary"
 | 
			
		||||
                      fill="Soft"
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      type="button"
 | 
			
		||||
                      outlined
 | 
			
		||||
                      onClick={() => pickFiles('image/*')}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Select</Text>
 | 
			
		||||
                    </Button>
 | 
			
		||||
                  }
 | 
			
		||||
                />
 | 
			
		||||
              </SequenceCard>
 | 
			
		||||
            )}
 | 
			
		||||
            {files.map((file) => (
 | 
			
		||||
              <SequenceCard
 | 
			
		||||
                key={file.name}
 | 
			
		||||
                style={{ padding: config.space.S300 }}
 | 
			
		||||
                variant="SurfaceVariant"
 | 
			
		||||
                direction="Column"
 | 
			
		||||
                gap="400"
 | 
			
		||||
              >
 | 
			
		||||
                <ImageTileUpload file={file}>
 | 
			
		||||
                  {(uploadAtom) => (
 | 
			
		||||
                    <CompactUploadCardRenderer
 | 
			
		||||
                      uploadAtom={uploadAtom}
 | 
			
		||||
                      onRemove={handleUploadRemove}
 | 
			
		||||
                      onComplete={handleUploadComplete}
 | 
			
		||||
                    />
 | 
			
		||||
                  )}
 | 
			
		||||
                </ImageTileUpload>
 | 
			
		||||
              </SequenceCard>
 | 
			
		||||
            ))}
 | 
			
		||||
            {uploadedImages.map(renderImage)}
 | 
			
		||||
            {images.map(renderImage)}
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										51
									
								
								src/app/components/image-pack-view/ImagePackView.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/app/components/image-pack-view/ImagePackView.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,51 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
 | 
			
		||||
import { PackAddress } from '../../plugins/custom-emoji';
 | 
			
		||||
import { Page, PageHeader, PageContent } from '../page';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { RoomImagePack } from './RoomImagePack';
 | 
			
		||||
import { UserImagePack } from './UserImagePack';
 | 
			
		||||
 | 
			
		||||
type ImagePackViewProps = {
 | 
			
		||||
  address: PackAddress | undefined;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const room = address && mx.getRoom(address.roomId);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false} balance>
 | 
			
		||||
        <Box alignItems="Center" grow="Yes" gap="200">
 | 
			
		||||
          <Box alignItems="Inherit" grow="Yes" gap="200">
 | 
			
		||||
            <Chip
 | 
			
		||||
              size="500"
 | 
			
		||||
              radii="Pill"
 | 
			
		||||
              onClick={requestClose}
 | 
			
		||||
              before={<Icon size="100" src={Icons.ArrowLeft} />}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="T300">Emojis & Stickers</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            {room && address ? (
 | 
			
		||||
              <RoomImagePack room={room} stateKey={address.stateKey} />
 | 
			
		||||
            ) : (
 | 
			
		||||
              <UserImagePack />
 | 
			
		||||
            )}
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										214
									
								
								src/app/components/image-pack-view/ImageTile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										214
									
								
								src/app/components/image-pack-view/ImageTile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,214 @@
 | 
			
		|||
import React, { FormEventHandler, ReactNode, useMemo, useState } from 'react';
 | 
			
		||||
import { Badge, Box, Button, Chip, Icon, Icons, Input, Text } from 'folds';
 | 
			
		||||
import { UsageSwitcher, useUsageStr } from './UsageSwitcher';
 | 
			
		||||
import { mxcUrlToHttp } from '../../utils/matrix';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/custom-emoji';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { SettingTile } from '../setting-tile';
 | 
			
		||||
import { useObjectURL } from '../../hooks/useObjectURL';
 | 
			
		||||
import { createUploadAtom, TUploadAtom } from '../../state/upload';
 | 
			
		||||
import { replaceSpaceWithDash } from '../../utils/common';
 | 
			
		||||
 | 
			
		||||
type ImageTileProps = {
 | 
			
		||||
  defaultShortcode: string;
 | 
			
		||||
  useAuthentication: boolean;
 | 
			
		||||
  packUsage: ImageUsage[];
 | 
			
		||||
  image: PackImageReader;
 | 
			
		||||
  canEdit?: boolean;
 | 
			
		||||
  onEdit?: (defaultShortcode: string, image: PackImageReader) => void;
 | 
			
		||||
  deleted?: boolean;
 | 
			
		||||
  onDeleteToggle?: (defaultShortcode: string) => void;
 | 
			
		||||
};
 | 
			
		||||
export function ImageTile({
 | 
			
		||||
  defaultShortcode,
 | 
			
		||||
  image,
 | 
			
		||||
  packUsage,
 | 
			
		||||
  useAuthentication,
 | 
			
		||||
  canEdit,
 | 
			
		||||
  onEdit,
 | 
			
		||||
  onDeleteToggle,
 | 
			
		||||
  deleted,
 | 
			
		||||
}: ImageTileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const getUsageStr = useUsageStr();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      before={
 | 
			
		||||
        <img
 | 
			
		||||
          className={css.ImagePackImage}
 | 
			
		||||
          src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
 | 
			
		||||
          alt={image.shortcode}
 | 
			
		||||
          loading="lazy"
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      title={
 | 
			
		||||
        deleted ? (
 | 
			
		||||
          <span className={css.DeleteImageShortcode}>{image.shortcode}</span>
 | 
			
		||||
        ) : (
 | 
			
		||||
          image.shortcode
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
      description={
 | 
			
		||||
        <Box as="span" gap="200">
 | 
			
		||||
          {image.usage && getUsageStr(image.usage) !== getUsageStr(packUsage) && (
 | 
			
		||||
            <Badge as="span" variant="Secondary" size="400" radii="300" outlined>
 | 
			
		||||
              <Text as="span" size="L400">
 | 
			
		||||
                {getUsageStr(image.usage)}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Badge>
 | 
			
		||||
          )}
 | 
			
		||||
          {image.body}
 | 
			
		||||
        </Box>
 | 
			
		||||
      }
 | 
			
		||||
      after={
 | 
			
		||||
        canEdit ? (
 | 
			
		||||
          <Box shrink="No" alignItems="Center" gap="200">
 | 
			
		||||
            <Chip
 | 
			
		||||
              variant={deleted ? 'Critical' : 'Secondary'}
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="Pill"
 | 
			
		||||
              onClick={() => onDeleteToggle?.(defaultShortcode)}
 | 
			
		||||
            >
 | 
			
		||||
              {deleted ? <Text size="B300">Undo</Text> : <Icon size="50" src={Icons.Delete} />}
 | 
			
		||||
            </Chip>
 | 
			
		||||
            {!deleted && (
 | 
			
		||||
              <Chip
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
                radii="Pill"
 | 
			
		||||
                onClick={() => onEdit?.(defaultShortcode, image)}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">Edit</Text>
 | 
			
		||||
              </Chip>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        ) : undefined
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ImageTileUploadProps = {
 | 
			
		||||
  file: File;
 | 
			
		||||
  children: (uploadAtom: TUploadAtom) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function ImageTileUpload({ file, children }: ImageTileUploadProps) {
 | 
			
		||||
  const url = useObjectURL(file);
 | 
			
		||||
  const uploadAtom = useMemo(() => createUploadAtom(file), [file]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile before={<img className={css.ImagePackImage} src={url} alt={file.name} />}>
 | 
			
		||||
      {children(uploadAtom)}
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ImageTileEditProps = {
 | 
			
		||||
  defaultShortcode: string;
 | 
			
		||||
  useAuthentication: boolean;
 | 
			
		||||
  packUsage: ImageUsage[];
 | 
			
		||||
  image: PackImageReader;
 | 
			
		||||
  onCancel: (shortcode: string) => void;
 | 
			
		||||
  onSave: (shortcode: string, image: PackImageReader) => void;
 | 
			
		||||
};
 | 
			
		||||
export function ImageTileEdit({
 | 
			
		||||
  defaultShortcode,
 | 
			
		||||
  useAuthentication,
 | 
			
		||||
  packUsage,
 | 
			
		||||
  image,
 | 
			
		||||
  onCancel,
 | 
			
		||||
  onSave,
 | 
			
		||||
}: ImageTileEditProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const defaultUsage = image.usage ?? packUsage;
 | 
			
		||||
 | 
			
		||||
  const [unsavedUsage, setUnsavedUsages] = useState(defaultUsage);
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const shortcodeInput = target?.shortcodeInput as HTMLInputElement | undefined;
 | 
			
		||||
    const bodyInput = target?.bodyInput as HTMLTextAreaElement | undefined;
 | 
			
		||||
    if (!shortcodeInput || !bodyInput) return;
 | 
			
		||||
 | 
			
		||||
    const shortcode = replaceSpaceWithDash(shortcodeInput.value.trim());
 | 
			
		||||
    const body = bodyInput.value.trim() || undefined;
 | 
			
		||||
    const usage = unsavedUsage;
 | 
			
		||||
 | 
			
		||||
    if (!shortcode) return;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      shortcode === image.shortcode &&
 | 
			
		||||
      body === image.body &&
 | 
			
		||||
      imageUsageEqual(usage, defaultUsage)
 | 
			
		||||
    ) {
 | 
			
		||||
      onCancel(defaultShortcode);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const imageReader = new PackImageReader(shortcode, image.url, {
 | 
			
		||||
      info: image.info,
 | 
			
		||||
      body,
 | 
			
		||||
      usage: imageUsageEqual(usage, packUsage) ? undefined : usage,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    onSave(defaultShortcode, imageReader);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      before={
 | 
			
		||||
        <img
 | 
			
		||||
          className={css.ImagePackImage}
 | 
			
		||||
          src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
 | 
			
		||||
          alt={image.shortcode}
 | 
			
		||||
          loading="lazy"
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
 | 
			
		||||
        <Box direction="Column" className={css.ImagePackImageInputs}>
 | 
			
		||||
          <Input
 | 
			
		||||
            before={<Text size="L400">Shortcode:</Text>}
 | 
			
		||||
            defaultValue={image.shortcode}
 | 
			
		||||
            name="shortcodeInput"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            size="300"
 | 
			
		||||
            radii="0"
 | 
			
		||||
            required
 | 
			
		||||
            autoFocus
 | 
			
		||||
          />
 | 
			
		||||
          <Input
 | 
			
		||||
            before={<Text size="L400">Body:</Text>}
 | 
			
		||||
            defaultValue={image.body}
 | 
			
		||||
            name="bodyInput"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            size="300"
 | 
			
		||||
            radii="0"
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box gap="200">
 | 
			
		||||
          <Box shrink="No" direction="Column">
 | 
			
		||||
            <UsageSwitcher usage={unsavedUsage} onChange={setUnsavedUsages} canEdit />
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box grow="Yes" />
 | 
			
		||||
          <Button type="submit" variant="Success" size="300" radii="300">
 | 
			
		||||
            <Text size="B300">Save</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button
 | 
			
		||||
            type="reset"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            size="300"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            onClick={() => onCancel(defaultShortcode)}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Cancel</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										232
									
								
								src/app/components/image-pack-view/PackMeta.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								src/app/components/image-pack-view/PackMeta.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,232 @@
 | 
			
		|||
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  Avatar,
 | 
			
		||||
  AvatarImage,
 | 
			
		||||
  AvatarFallback,
 | 
			
		||||
  Button,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Input,
 | 
			
		||||
  TextArea,
 | 
			
		||||
  Chip,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import Linkify from 'linkify-react';
 | 
			
		||||
import { mxcUrlToHttp } from '../../utils/matrix';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { nameInitials } from '../../utils/common';
 | 
			
		||||
import { BreakWord } from '../../styles/Text.css';
 | 
			
		||||
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
 | 
			
		||||
import { ContainerColor } from '../../styles/ContainerColor.css';
 | 
			
		||||
import { useFilePicker } from '../../hooks/useFilePicker';
 | 
			
		||||
import { useObjectURL } from '../../hooks/useObjectURL';
 | 
			
		||||
import { createUploadAtom, UploadSuccess } from '../../state/upload';
 | 
			
		||||
import { CompactUploadCardRenderer } from '../upload-card';
 | 
			
		||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
			
		||||
import { PackMetaReader } from '../../plugins/custom-emoji';
 | 
			
		||||
 | 
			
		||||
type ImagePackAvatarProps = {
 | 
			
		||||
  url?: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
};
 | 
			
		||||
function ImagePackAvatar({ url, name }: ImagePackAvatarProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Avatar size="500" className={ContainerColor({ variant: 'Secondary' })}>
 | 
			
		||||
      {url ? (
 | 
			
		||||
        <AvatarImage src={url} alt={name ?? 'Unknown'} />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <AvatarFallback>
 | 
			
		||||
          <Text size="H2">{nameInitials(name ?? 'Unknown')}</Text>
 | 
			
		||||
        </AvatarFallback>
 | 
			
		||||
      )}
 | 
			
		||||
    </Avatar>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ImagePackProfileProps = {
 | 
			
		||||
  meta: PackMetaReader;
 | 
			
		||||
  canEdit?: boolean;
 | 
			
		||||
  onEdit?: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function ImagePackProfile({ meta, canEdit, onEdit }: ImagePackProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const avatarUrl = meta.avatar
 | 
			
		||||
    ? mxcUrlToHttp(mx, meta.avatar, useAuthentication) ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box gap="400">
 | 
			
		||||
      <Box grow="Yes" direction="Column" gap="300">
 | 
			
		||||
        <Box direction="Column" gap="100">
 | 
			
		||||
          <Text className={BreakWord} size="H5">
 | 
			
		||||
            {meta.name ?? 'Unknown'}
 | 
			
		||||
          </Text>
 | 
			
		||||
          {meta.attribution && (
 | 
			
		||||
            <Text className={BreakWord} size="T200">
 | 
			
		||||
              <Linkify options={LINKIFY_OPTS}>{meta.attribution}</Linkify>
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        {canEdit && (
 | 
			
		||||
          <Box gap="200">
 | 
			
		||||
            <Chip
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              before={<Icon size="50" src={Icons.Pencil} />}
 | 
			
		||||
              onClick={onEdit}
 | 
			
		||||
              outlined
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Edit</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box shrink="No">
 | 
			
		||||
        <ImagePackAvatar url={avatarUrl} name={meta.name} />
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ImagePackProfileEditProps = {
 | 
			
		||||
  meta: PackMetaReader;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  onSave: (meta: PackMetaReader) => void;
 | 
			
		||||
};
 | 
			
		||||
export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfileEditProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const [avatar, setAvatar] = useState(meta.avatar);
 | 
			
		||||
 | 
			
		||||
  const avatarUrl = avatar ? mxcUrlToHttp(mx, avatar, useAuthentication) ?? undefined : undefined;
 | 
			
		||||
 | 
			
		||||
  const [imageFile, setImageFile] = useState<File>();
 | 
			
		||||
  const avatarFileUrl = useObjectURL(imageFile);
 | 
			
		||||
  const uploadingAvatar = avatarFileUrl ? avatar === meta.avatar : false;
 | 
			
		||||
  const uploadAtom = useMemo(() => {
 | 
			
		||||
    if (imageFile) return createUploadAtom(imageFile);
 | 
			
		||||
    return undefined;
 | 
			
		||||
  }, [imageFile]);
 | 
			
		||||
 | 
			
		||||
  const pickFile = useFilePicker(setImageFile, false);
 | 
			
		||||
 | 
			
		||||
  const handleRemoveUpload = useCallback(() => {
 | 
			
		||||
    setImageFile(undefined);
 | 
			
		||||
    setAvatar(meta.avatar);
 | 
			
		||||
  }, [meta.avatar]);
 | 
			
		||||
 | 
			
		||||
  const handleUploaded = useCallback((upload: UploadSuccess) => {
 | 
			
		||||
    setAvatar(upload.mxc);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (uploadingAvatar) return;
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const nameInput = target?.nameInput as HTMLInputElement | undefined;
 | 
			
		||||
    const attributionTextArea = target?.attributionTextArea as HTMLTextAreaElement | undefined;
 | 
			
		||||
    if (!nameInput || !attributionTextArea) return;
 | 
			
		||||
 | 
			
		||||
    const name = nameInput.value.trim();
 | 
			
		||||
    const attribution = attributionTextArea.value.trim();
 | 
			
		||||
    if (!name) return;
 | 
			
		||||
 | 
			
		||||
    const metaReader = new PackMetaReader({
 | 
			
		||||
      avatar_url: avatar,
 | 
			
		||||
      display_name: name,
 | 
			
		||||
      attribution,
 | 
			
		||||
    });
 | 
			
		||||
    onSave(metaReader);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
 | 
			
		||||
      <Box gap="400">
 | 
			
		||||
        <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
          <Text size="L400">Pack Avatar</Text>
 | 
			
		||||
          {uploadAtom ? (
 | 
			
		||||
            <Box gap="200" direction="Column">
 | 
			
		||||
              <CompactUploadCardRenderer
 | 
			
		||||
                uploadAtom={uploadAtom}
 | 
			
		||||
                onRemove={handleRemoveUpload}
 | 
			
		||||
                onComplete={handleUploaded}
 | 
			
		||||
              />
 | 
			
		||||
            </Box>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <Box gap="200">
 | 
			
		||||
              <Button
 | 
			
		||||
                type="button"
 | 
			
		||||
                size="300"
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
                fill="Soft"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                onClick={() => pickFile('image/*')}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">Upload</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
              {!avatar && meta.avatar && (
 | 
			
		||||
                <Button
 | 
			
		||||
                  type="button"
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Success"
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  onClick={() => setAvatar(meta.avatar)}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">Reset</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              )}
 | 
			
		||||
              {avatar && (
 | 
			
		||||
                <Button
 | 
			
		||||
                  type="button"
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  onClick={() => setAvatar(undefined)}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">Remove</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              )}
 | 
			
		||||
            </Box>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No">
 | 
			
		||||
          <ImagePackAvatar url={avatarFileUrl ?? avatarUrl} name={meta.name} />
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box direction="Inherit" gap="100">
 | 
			
		||||
        <Text size="L400">Name</Text>
 | 
			
		||||
        <Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box direction="Inherit" gap="100">
 | 
			
		||||
        <Text size="L400">Attribution</Text>
 | 
			
		||||
        <TextArea
 | 
			
		||||
          name="attributionTextArea"
 | 
			
		||||
          defaultValue={meta.attribution}
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          radii="300"
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box gap="300">
 | 
			
		||||
        <Button type="submit" variant="Success" size="300" radii="300" disabled={uploadingAvatar}>
 | 
			
		||||
          <Text size="B300">Save</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          type="reset"
 | 
			
		||||
          onClick={onCancel}
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          fill="Soft"
 | 
			
		||||
          size="300"
 | 
			
		||||
          radii="300"
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B300">Cancel</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								src/app/components/image-pack-view/RoomImagePack.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/app/components/image-pack-view/RoomImagePack.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,55 @@
 | 
			
		|||
import React, { useCallback, useMemo } from 'react';
 | 
			
		||||
import { Room } from 'matrix-js-sdk';
 | 
			
		||||
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { ImagePackContent } from './ImagePackContent';
 | 
			
		||||
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
 | 
			
		||||
import { StateEvent } from '../../../types/matrix/room';
 | 
			
		||||
import { useRoomImagePack } from '../../hooks/useImagePacks';
 | 
			
		||||
import { randomStr } from '../../utils/common';
 | 
			
		||||
 | 
			
		||||
type RoomImagePackProps = {
 | 
			
		||||
  room: Room;
 | 
			
		||||
  stateKey: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
  const powerLevels = usePowerLevels(room);
 | 
			
		||||
 | 
			
		||||
  const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
 | 
			
		||||
  const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(userId));
 | 
			
		||||
 | 
			
		||||
  const fallbackPack = useMemo(() => {
 | 
			
		||||
    const fakePackId = randomStr(4);
 | 
			
		||||
    return new ImagePack(
 | 
			
		||||
      fakePackId,
 | 
			
		||||
      {},
 | 
			
		||||
      {
 | 
			
		||||
        roomId: room.roomId,
 | 
			
		||||
        stateKey,
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
  }, [room.roomId, stateKey]);
 | 
			
		||||
  const imagePack = useRoomImagePack(room, stateKey) ?? fallbackPack;
 | 
			
		||||
 | 
			
		||||
  const handleUpdate = useCallback(
 | 
			
		||||
    async (packContent: PackContent) => {
 | 
			
		||||
      const { address } = imagePack;
 | 
			
		||||
      if (!address) return;
 | 
			
		||||
 | 
			
		||||
      await mx.sendStateEvent(
 | 
			
		||||
        address.roomId,
 | 
			
		||||
        StateEvent.PoniesRoomEmotes,
 | 
			
		||||
        packContent,
 | 
			
		||||
        address.stateKey
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    [mx, imagePack]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ImagePackContent imagePack={imagePack} canEdit={canEditImagePack} onUpdate={handleUpdate} />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										116
									
								
								src/app/components/image-pack-view/UsageSwitcher.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/app/components/image-pack-view/UsageSwitcher.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,116 @@
 | 
			
		|||
import React, { MouseEventHandler, useMemo, useState } from 'react';
 | 
			
		||||
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { ImageUsage } from '../../plugins/custom-emoji';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
export const useUsageStr = (): ((usage: ImageUsage[]) => string) => {
 | 
			
		||||
  const getUsageStr = (usage: ImageUsage[]): string => {
 | 
			
		||||
    const sticker = usage.includes(ImageUsage.Sticker);
 | 
			
		||||
    const emoticon = usage.includes(ImageUsage.Emoticon);
 | 
			
		||||
 | 
			
		||||
    if (sticker && emoticon) return 'Both';
 | 
			
		||||
    if (sticker) return 'Sticker';
 | 
			
		||||
    if (emoticon) return 'Emoji';
 | 
			
		||||
    return 'Both';
 | 
			
		||||
  };
 | 
			
		||||
  return getUsageStr;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type UsageSelectorProps = {
 | 
			
		||||
  selected: ImageUsage[];
 | 
			
		||||
  onChange: (usage: ImageUsage[]) => void;
 | 
			
		||||
};
 | 
			
		||||
export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
 | 
			
		||||
  const getUsageStr = useUsageStr();
 | 
			
		||||
 | 
			
		||||
  const selectedUsageStr = getUsageStr(selected);
 | 
			
		||||
  const isSelected = (usage: ImageUsage[]) => getUsageStr(usage) === selectedUsageStr;
 | 
			
		||||
 | 
			
		||||
  const allUsages: ImageUsage[][] = useMemo(
 | 
			
		||||
    () => [[ImageUsage.Emoticon], [ImageUsage.Sticker], [ImageUsage.Sticker, ImageUsage.Emoticon]],
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
      {allUsages.map((usage) => (
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          key={getUsageStr(usage)}
 | 
			
		||||
          size="300"
 | 
			
		||||
          variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
 | 
			
		||||
          aria-selected={isSelected(usage)}
 | 
			
		||||
          radii="300"
 | 
			
		||||
          onClick={() => onChange(usage)}
 | 
			
		||||
        >
 | 
			
		||||
          <Box grow="Yes">
 | 
			
		||||
            <Text size="T300">{getUsageStr(usage)}</Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </MenuItem>
 | 
			
		||||
      ))}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UsageSwitcherProps = {
 | 
			
		||||
  usage: ImageUsage[];
 | 
			
		||||
  canEdit?: boolean;
 | 
			
		||||
  onChange: (usage: ImageUsage[]) => void;
 | 
			
		||||
};
 | 
			
		||||
export function UsageSwitcher({ usage, onChange, canEdit }: UsageSwitcherProps) {
 | 
			
		||||
  const getUsageStr = useUsageStr();
 | 
			
		||||
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleSelectUsage: MouseEventHandler<HTMLButtonElement> = (event) => {
 | 
			
		||||
    setMenuCords(event.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Button
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        size="300"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        type="button"
 | 
			
		||||
        outlined
 | 
			
		||||
        aria-disabled={!canEdit}
 | 
			
		||||
        after={canEdit && <Icon src={Icons.ChevronBottom} size="100" />}
 | 
			
		||||
        onClick={canEdit ? handleSelectUsage : undefined}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="B300">{getUsageStr(usage)}</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <UsageSelector
 | 
			
		||||
                selected={usage}
 | 
			
		||||
                onChange={(usg) => {
 | 
			
		||||
                  setMenuCords(undefined);
 | 
			
		||||
                  onChange(usg);
 | 
			
		||||
                }}
 | 
			
		||||
              />
 | 
			
		||||
            </Menu>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								src/app/components/image-pack-view/UserImagePack.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/app/components/image-pack-view/UserImagePack.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
import React, { useCallback, useMemo } from 'react';
 | 
			
		||||
import { ImagePackContent } from './ImagePackContent';
 | 
			
		||||
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
 | 
			
		||||
import { useUserImagePack } from '../../hooks/useImagePacks';
 | 
			
		||||
 | 
			
		||||
export function UserImagePack() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]);
 | 
			
		||||
  const imagePack = useUserImagePack();
 | 
			
		||||
 | 
			
		||||
  const handleUpdate = useCallback(
 | 
			
		||||
    async (packContent: PackContent) => {
 | 
			
		||||
      await mx.setAccountData(AccountDataEvent.PoniesUserEmotes, packContent);
 | 
			
		||||
    },
 | 
			
		||||
    [mx]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return <ImagePackContent imagePack={imagePack ?? defaultPack} canEdit onUpdate={handleUpdate} />;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/image-pack-view/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/image-pack-view/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './ImagePackView';
 | 
			
		||||
							
								
								
									
										37
									
								
								src/app/components/image-pack-view/style.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/app/components/image-pack-view/style.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { color, config, DefaultReset, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const ImagePackImage = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    width: toRem(36),
 | 
			
		||||
    height: toRem(36),
 | 
			
		||||
    objectFit: 'contain',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const DeleteImageShortcode = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    color: color.Critical.Main,
 | 
			
		||||
    textDecoration: 'line-through',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const ImagePackImageInputs = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    overflow: 'hidden',
 | 
			
		||||
    borderRadius: config.radii.R300,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const UnsavedMenu = style({
 | 
			
		||||
  position: 'sticky',
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
  paddingLeft: config.space.S400,
 | 
			
		||||
  top: config.space.S400,
 | 
			
		||||
  left: config.space.S400,
 | 
			
		||||
  right: 0,
 | 
			
		||||
  zIndex: 1,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										53
									
								
								src/app/components/info-card/InfoCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/app/components/info-card/InfoCard.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
import { Box, ContainerColor, Text } from 'folds';
 | 
			
		||||
import React, { ReactNode } from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { BreakWord } from '../../styles/Text.css';
 | 
			
		||||
import { ContainerColor as ContainerClr } from '../../styles/ContainerColor.css';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
type InfoCardProps = {
 | 
			
		||||
  variant?: ContainerColor;
 | 
			
		||||
  title?: ReactNode;
 | 
			
		||||
  description?: ReactNode;
 | 
			
		||||
  before?: ReactNode;
 | 
			
		||||
  after?: ReactNode;
 | 
			
		||||
  children?: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function InfoCard({
 | 
			
		||||
  variant = 'Primary',
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  before,
 | 
			
		||||
  after,
 | 
			
		||||
  children,
 | 
			
		||||
}: InfoCardProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      className={classNames(css.InfoCard, ContainerClr({ variant }))}
 | 
			
		||||
      gap="300"
 | 
			
		||||
    >
 | 
			
		||||
      <Box gap="200" alignItems="Center">
 | 
			
		||||
        {before && (
 | 
			
		||||
          <Box shrink="No" alignSelf="Start">
 | 
			
		||||
            {before}
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
        <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
          {title && (
 | 
			
		||||
            <Text size="L400" className={BreakWord}>
 | 
			
		||||
              {title}
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
          {description && (
 | 
			
		||||
            <Text size="T200" className={BreakWord}>
 | 
			
		||||
              {description}
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        {after && <Box shrink="No">{after}</Box>}
 | 
			
		||||
      </Box>
 | 
			
		||||
      {children}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/info-card/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/info-card/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './InfoCard';
 | 
			
		||||
							
								
								
									
										10
									
								
								src/app/components/info-card/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/components/info-card/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const InfoCard = style([
 | 
			
		||||
  {
 | 
			
		||||
    padding: config.space.S200,
 | 
			
		||||
    borderRadius: config.radii.R300,
 | 
			
		||||
    borderWidth: config.borderWidth.B300,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import { Box, Icon, IconSrc } from 'folds';
 | 
			
		||||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { CompactLayout, ModernLayout } from '..';
 | 
			
		||||
import { MessageLayout } from '../../../state/settings';
 | 
			
		||||
 | 
			
		||||
export type EventContentProps = {
 | 
			
		||||
  messageLayout: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -11,9 +12,9 @@ export type EventContentProps = {
 | 
			
		|||
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
 | 
			
		||||
  const beforeJSX = (
 | 
			
		||||
    <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
 | 
			
		||||
      {messageLayout === 1 && time}
 | 
			
		||||
      {messageLayout === MessageLayout.Compact && time}
 | 
			
		||||
      <Box
 | 
			
		||||
        grow={messageLayout === 1 ? undefined : 'Yes'}
 | 
			
		||||
        grow={messageLayout === MessageLayout.Compact ? undefined : 'Yes'}
 | 
			
		||||
        alignItems="Center"
 | 
			
		||||
        justifyContent="Center"
 | 
			
		||||
      >
 | 
			
		||||
| 
						 | 
				
			
			@ -25,11 +26,11 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
 | 
			
		|||
  const msgContentJSX = (
 | 
			
		||||
    <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
 | 
			
		||||
      {content}
 | 
			
		||||
      {messageLayout !== 1 && time}
 | 
			
		||||
      {messageLayout !== MessageLayout.Compact && time}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return messageLayout === 1 ? (
 | 
			
		||||
  return messageLayout === MessageLayout.Compact ? (
 | 
			
		||||
    <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,7 +27,6 @@ import {
 | 
			
		|||
  getFileNameExt,
 | 
			
		||||
  mimeTypeToExt,
 | 
			
		||||
} from '../../../utils/mimeTypes';
 | 
			
		||||
import * as css from './style.css';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import {
 | 
			
		||||
  decryptFile,
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +35,7 @@ import {
 | 
			
		|||
  mxcUrlToHttp,
 | 
			
		||||
} from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { ModalWide } from '../../../styles/Modal.css';
 | 
			
		||||
 | 
			
		||||
const renderErrorButton = (retry: () => void, text: string) => (
 | 
			
		||||
  <TooltipProvider
 | 
			
		||||
| 
						 | 
				
			
			@ -111,7 +111,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
 | 
			
		|||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal
 | 
			
		||||
                className={css.ModalWide}
 | 
			
		||||
                className={ModalWide}
 | 
			
		||||
                size="500"
 | 
			
		||||
                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
			
		||||
              >
 | 
			
		||||
| 
						 | 
				
			
			@ -199,7 +199,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
 | 
			
		|||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal
 | 
			
		||||
                className={css.ModalWide}
 | 
			
		||||
                className={ModalWide}
 | 
			
		||||
                size="500"
 | 
			
		||||
                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
			
		||||
              >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,6 +28,7 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
 | 
			
		|||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { ModalWide } from '../../../styles/Modal.css';
 | 
			
		||||
 | 
			
		||||
type RenderViewerProps = {
 | 
			
		||||
  src: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +122,7 @@ export const ImageContent = as<'div', ImageContentProps>(
 | 
			
		|||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Modal
 | 
			
		||||
                  className={css.ModalWide}
 | 
			
		||||
                  className={ModalWide}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
			
		||||
                >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,8 +30,3 @@ export const AbsoluteFooter = style([
 | 
			
		|||
    right: config.space.S100,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const ModalWide = style({
 | 
			
		||||
  minWidth: '85vw',
 | 
			
		||||
  minHeight: '90vh',
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,14 +27,14 @@ export function PageRoot({ nav, children }: PageRootProps) {
 | 
			
		|||
type ClientDrawerLayoutProps = {
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function PageNav({ children }: ClientDrawerLayoutProps) {
 | 
			
		||||
export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
  const isMobile = screenSize === ScreenSize.Mobile;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box
 | 
			
		||||
      grow={isMobile ? 'Yes' : undefined}
 | 
			
		||||
      className={css.PageNav}
 | 
			
		||||
      className={css.PageNav({ size })}
 | 
			
		||||
      shrink={isMobile ? 'Yes' : 'No'}
 | 
			
		||||
    >
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
| 
						 | 
				
			
			@ -44,15 +44,17 @@ export function PageNav({ children }: ClientDrawerLayoutProps) {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => (
 | 
			
		||||
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
 | 
			
		||||
  ({ className, outlined, ...props }, ref) => (
 | 
			
		||||
    <Header
 | 
			
		||||
    className={classNames(css.PageNavHeader, className)}
 | 
			
		||||
      className={classNames(css.PageNavHeader({ outlined }), className)}
 | 
			
		||||
      variant="Background"
 | 
			
		||||
      size="600"
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
));
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export function PageNavContent({
 | 
			
		||||
  scrollRef,
 | 
			
		||||
| 
						 | 
				
			
			@ -88,11 +90,11 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
 | 
			
		|||
));
 | 
			
		||||
 | 
			
		||||
export const PageHeader = as<'div', css.PageHeaderVariants>(
 | 
			
		||||
  ({ className, balance, ...props }, ref) => (
 | 
			
		||||
  ({ className, outlined, balance, ...props }, ref) => (
 | 
			
		||||
    <Header
 | 
			
		||||
      as="header"
 | 
			
		||||
      size="600"
 | 
			
		||||
      className={classNames(css.PageHeader({ balance }), className)}
 | 
			
		||||
      className={classNames(css.PageHeader({ balance, outlined }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,15 +2,27 @@ import { style } from '@vanilla-extract/css';
 | 
			
		|||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
 | 
			
		||||
import { DefaultReset, color, config, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const PageNav = style({
 | 
			
		||||
export const PageNav = recipe({
 | 
			
		||||
  variants: {
 | 
			
		||||
    size: {
 | 
			
		||||
      '400': {
 | 
			
		||||
        width: toRem(256),
 | 
			
		||||
      },
 | 
			
		||||
      '300': {
 | 
			
		||||
        width: toRem(222),
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  defaultVariants: {
 | 
			
		||||
    size: '400',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type PageNavVariants = RecipeVariants<typeof PageNav>;
 | 
			
		||||
 | 
			
		||||
export const PageNavHeader = style({
 | 
			
		||||
export const PageNavHeader = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
 | 
			
		||||
    flexShrink: 0,
 | 
			
		||||
  borderBottomWidth: 1,
 | 
			
		||||
 | 
			
		||||
    selectors: {
 | 
			
		||||
      'button&': {
 | 
			
		||||
        cursor: 'pointer',
 | 
			
		||||
| 
						 | 
				
			
			@ -25,7 +37,20 @@ export const PageNavHeader = style({
 | 
			
		|||
        backgroundColor: color.Background.ContainerActive,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  variants: {
 | 
			
		||||
    outlined: {
 | 
			
		||||
      true: {
 | 
			
		||||
        borderBottomWidth: 1,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  defaultVariants: {
 | 
			
		||||
    outlined: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type PageNavHeaderVariants = RecipeVariants<typeof PageNavHeader>;
 | 
			
		||||
 | 
			
		||||
export const PageNavContent = style({
 | 
			
		||||
  minHeight: '100%',
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +63,6 @@ export const PageHeader = recipe({
 | 
			
		|||
  base: {
 | 
			
		||||
    paddingLeft: config.space.S400,
 | 
			
		||||
    paddingRight: config.space.S200,
 | 
			
		||||
    borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    balance: {
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +70,14 @@ export const PageHeader = recipe({
 | 
			
		|||
        paddingLeft: config.space.S200,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    outlined: {
 | 
			
		||||
      true: {
 | 
			
		||||
        borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  defaultVariants: {
 | 
			
		||||
    outlined: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,7 @@ type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> &
 | 
			
		|||
  size: '400' | '500';
 | 
			
		||||
};
 | 
			
		||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
 | 
			
		||||
  ({ variant, size, style, after, ...props }, ref) => {
 | 
			
		||||
  ({ variant = 'Background', size, style, after, ...props }, ref) => {
 | 
			
		||||
    const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								src/app/components/password-input/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/password-input/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './PasswordInput';
 | 
			
		||||
							
								
								
									
										32
									
								
								src/app/components/setting-tile/SettingTile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/app/components/setting-tile/SettingTile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { Box, Text } from 'folds';
 | 
			
		||||
import { BreakWord } from '../../styles/Text.css';
 | 
			
		||||
 | 
			
		||||
type SettingTileProps = {
 | 
			
		||||
  title?: ReactNode;
 | 
			
		||||
  description?: ReactNode;
 | 
			
		||||
  before?: ReactNode;
 | 
			
		||||
  after?: ReactNode;
 | 
			
		||||
  children?: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function SettingTile({ title, description, before, after, children }: SettingTileProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box alignItems="Center" gap="300">
 | 
			
		||||
      {before && <Box shrink="No">{before}</Box>}
 | 
			
		||||
      <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
        {title && (
 | 
			
		||||
          <Text className={BreakWord} size="T300">
 | 
			
		||||
            {title}
 | 
			
		||||
          </Text>
 | 
			
		||||
        )}
 | 
			
		||||
        {description && (
 | 
			
		||||
          <Text className={BreakWord} size="T200" priority="300">
 | 
			
		||||
            {description}
 | 
			
		||||
          </Text>
 | 
			
		||||
        )}
 | 
			
		||||
        {children}
 | 
			
		||||
      </Box>
 | 
			
		||||
      {after && <Box shrink="No">{after}</Box>}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/components/setting-tile/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/components/setting-tile/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './SettingTile';
 | 
			
		||||
							
								
								
									
										89
									
								
								src/app/components/uia-stages/PasswordStage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/app/components/uia-stages/PasswordStage.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
 | 
			
		||||
import React, { FormEventHandler } from 'react';
 | 
			
		||||
import { AuthType } from 'matrix-js-sdk';
 | 
			
		||||
import { StageComponentProps } from './types';
 | 
			
		||||
import { ErrorCode } from '../../cs-errorcode';
 | 
			
		||||
import { PasswordInput } from '../password-input';
 | 
			
		||||
 | 
			
		||||
export function PasswordStage({
 | 
			
		||||
  stageData,
 | 
			
		||||
  submitAuthDict,
 | 
			
		||||
  onCancel,
 | 
			
		||||
  userId,
 | 
			
		||||
}: StageComponentProps & {
 | 
			
		||||
  userId: string;
 | 
			
		||||
}) {
 | 
			
		||||
  const { errorCode, error, session } = stageData;
 | 
			
		||||
 | 
			
		||||
  const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    const { passwordInput } = evt.target as HTMLFormElement & {
 | 
			
		||||
      passwordInput: HTMLInputElement;
 | 
			
		||||
    };
 | 
			
		||||
    const password = passwordInput.value;
 | 
			
		||||
    if (!password) return;
 | 
			
		||||
    submitAuthDict({
 | 
			
		||||
      type: AuthType.Password,
 | 
			
		||||
      identifier: {
 | 
			
		||||
        type: 'm.id.user',
 | 
			
		||||
        user: userId,
 | 
			
		||||
      },
 | 
			
		||||
      password,
 | 
			
		||||
      session,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog>
 | 
			
		||||
      <Header
 | 
			
		||||
        style={{
 | 
			
		||||
          padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
        }}
 | 
			
		||||
        variant="Surface"
 | 
			
		||||
        size="500"
 | 
			
		||||
      >
 | 
			
		||||
        <Box grow="Yes">
 | 
			
		||||
          <Text size="H4">Account Password</Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <IconButton size="300" onClick={onCancel} radii="300">
 | 
			
		||||
          <Icon src={Icons.Cross} />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
      </Header>
 | 
			
		||||
      <Box
 | 
			
		||||
        as="form"
 | 
			
		||||
        onSubmit={handleFormSubmit}
 | 
			
		||||
        style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <Box direction="Column" gap="400">
 | 
			
		||||
          <Text size="T200">
 | 
			
		||||
            To perform this action you need to authenticate yourself by entering you account
 | 
			
		||||
            password.
 | 
			
		||||
          </Text>
 | 
			
		||||
          <Box direction="Column" gap="100">
 | 
			
		||||
            <Text size="L400">Password</Text>
 | 
			
		||||
            <PasswordInput size="400" name="passwordInput" outlined autoFocus required />
 | 
			
		||||
            {errorCode && (
 | 
			
		||||
              <Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
 | 
			
		||||
                <Icon size="50" src={Icons.Warning} filled />
 | 
			
		||||
                <Text size="T200">
 | 
			
		||||
                  <b>
 | 
			
		||||
                    {errorCode === ErrorCode.M_FORBIDDEN
 | 
			
		||||
                      ? 'Invalid Password!'
 | 
			
		||||
                      : `${errorCode}: ${error}`}
 | 
			
		||||
                  </b>
 | 
			
		||||
                </Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Button variant="Primary" type="submit">
 | 
			
		||||
          <Text as="span" size="B400">
 | 
			
		||||
            Continue
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/app/components/uia-stages/SSOStage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/app/components/uia-stages/SSOStage.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,91 @@
 | 
			
		|||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
 | 
			
		||||
import React, { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import { StageComponentProps } from './types';
 | 
			
		||||
 | 
			
		||||
export function SSOStage({
 | 
			
		||||
  ssoRedirectURL,
 | 
			
		||||
  stageData,
 | 
			
		||||
  submitAuthDict,
 | 
			
		||||
  onCancel,
 | 
			
		||||
}: StageComponentProps & {
 | 
			
		||||
  ssoRedirectURL: string;
 | 
			
		||||
}) {
 | 
			
		||||
  const { errorCode, error, session } = stageData;
 | 
			
		||||
  const [ssoWindow, setSSOWindow] = useState<Window>();
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = useCallback(() => {
 | 
			
		||||
    submitAuthDict({
 | 
			
		||||
      session,
 | 
			
		||||
    });
 | 
			
		||||
  }, [submitAuthDict, session]);
 | 
			
		||||
 | 
			
		||||
  const handleContinue = () => {
 | 
			
		||||
    const w = window.open(ssoRedirectURL, '_blank');
 | 
			
		||||
    setSSOWindow(w ?? undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleMessage = (evt: MessageEvent) => {
 | 
			
		||||
      if (ssoWindow && evt.data === 'authDone' && evt.source === ssoWindow) {
 | 
			
		||||
        ssoWindow.close();
 | 
			
		||||
        setSSOWindow(undefined);
 | 
			
		||||
        handleSubmit();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    window.addEventListener('message', handleMessage);
 | 
			
		||||
    return () => {
 | 
			
		||||
      window.removeEventListener('message', handleMessage);
 | 
			
		||||
    };
 | 
			
		||||
  }, [ssoWindow, handleSubmit]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog>
 | 
			
		||||
      <Header
 | 
			
		||||
        style={{
 | 
			
		||||
          padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
        }}
 | 
			
		||||
        variant="Surface"
 | 
			
		||||
        size="500"
 | 
			
		||||
      >
 | 
			
		||||
        <Box grow="Yes">
 | 
			
		||||
          <Text size="H4">SSO Login</Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <IconButton size="300" onClick={onCancel} radii="300">
 | 
			
		||||
          <Icon src={Icons.Cross} />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
      </Header>
 | 
			
		||||
      <Box
 | 
			
		||||
        style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="T200">
 | 
			
		||||
          To perform this action you need to authenticate yourself by SSO login.
 | 
			
		||||
        </Text>
 | 
			
		||||
        {errorCode && (
 | 
			
		||||
          <Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
 | 
			
		||||
            <Icon size="50" src={Icons.Warning} filled />
 | 
			
		||||
            <Text size="T200">
 | 
			
		||||
              <b>{`${errorCode}: ${error}`}</b>
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {ssoWindow ? (
 | 
			
		||||
          <Button variant="Primary" onClick={handleSubmit}>
 | 
			
		||||
            <Text as="span" size="B400">
 | 
			
		||||
              Continue
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Button variant="Primary" onClick={handleContinue}>
 | 
			
		||||
            <Text as="span" size="B400">
 | 
			
		||||
              Continue with SSO
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
export * from './types';
 | 
			
		||||
export * from './DummyStage';
 | 
			
		||||
export * from './EmailStage';
 | 
			
		||||
export * from './PasswordStage';
 | 
			
		||||
export * from './ReCaptchaStage';
 | 
			
		||||
export * from './RegistrationTokenStage';
 | 
			
		||||
export * from './SSOStage';
 | 
			
		||||
export * from './TermsStage';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										94
									
								
								src/app/components/upload-card/CompactUploadCardRenderer.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/app/components/upload-card/CompactUploadCardRenderer.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,94 @@
 | 
			
		|||
import React, { useEffect } from 'react';
 | 
			
		||||
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
 | 
			
		||||
import { UploadCard, UploadCardError, CompactUploadCardProgress } from './UploadCard';
 | 
			
		||||
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { TUploadContent } from '../../utils/matrix';
 | 
			
		||||
import { getFileTypeIcon } from '../../utils/common';
 | 
			
		||||
 | 
			
		||||
type CompactUploadCardRendererProps = {
 | 
			
		||||
  isEncrypted?: boolean;
 | 
			
		||||
  uploadAtom: TUploadAtom;
 | 
			
		||||
  onRemove: (file: TUploadContent) => void;
 | 
			
		||||
  onComplete?: (upload: UploadSuccess) => void;
 | 
			
		||||
};
 | 
			
		||||
export function CompactUploadCardRenderer({
 | 
			
		||||
  isEncrypted,
 | 
			
		||||
  uploadAtom,
 | 
			
		||||
  onRemove,
 | 
			
		||||
  onComplete,
 | 
			
		||||
}: CompactUploadCardRendererProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
 | 
			
		||||
  const { file } = upload;
 | 
			
		||||
 | 
			
		||||
  if (upload.status === UploadStatus.Idle) startUpload();
 | 
			
		||||
 | 
			
		||||
  const removeUpload = () => {
 | 
			
		||||
    cancelUpload();
 | 
			
		||||
    onRemove(file);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (upload.status === UploadStatus.Success) {
 | 
			
		||||
      onComplete?.(upload);
 | 
			
		||||
    }
 | 
			
		||||
  }, [upload, onComplete]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <UploadCard
 | 
			
		||||
      compact
 | 
			
		||||
      outlined
 | 
			
		||||
      radii="300"
 | 
			
		||||
      before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
 | 
			
		||||
      after={
 | 
			
		||||
        <>
 | 
			
		||||
          {upload.status === UploadStatus.Error && (
 | 
			
		||||
            <Chip
 | 
			
		||||
              as="button"
 | 
			
		||||
              onClick={startUpload}
 | 
			
		||||
              aria-label="Retry Upload"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              radii="Pill"
 | 
			
		||||
              outlined
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Retry</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          )}
 | 
			
		||||
          <IconButton
 | 
			
		||||
            onClick={removeUpload}
 | 
			
		||||
            aria-label="Cancel Upload"
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            radii="Pill"
 | 
			
		||||
            size="300"
 | 
			
		||||
          >
 | 
			
		||||
            <Icon src={Icons.Cross} size="200" />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {upload.status === UploadStatus.Success ? (
 | 
			
		||||
        <>
 | 
			
		||||
          <Text size="H6" truncate>
 | 
			
		||||
            {file.name}
 | 
			
		||||
          </Text>
 | 
			
		||||
          <Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
 | 
			
		||||
        </>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <>
 | 
			
		||||
          {upload.status === UploadStatus.Idle && (
 | 
			
		||||
            <CompactUploadCardProgress sentBytes={0} totalBytes={file.size} />
 | 
			
		||||
          )}
 | 
			
		||||
          {upload.status === UploadStatus.Loading && (
 | 
			
		||||
            <CompactUploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
 | 
			
		||||
          )}
 | 
			
		||||
          {upload.status === UploadStatus.Error && (
 | 
			
		||||
            <UploadCardError>
 | 
			
		||||
              <Text size="T200">{upload.error.message}</Text>
 | 
			
		||||
            </UploadCardError>
 | 
			
		||||
          )}
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </UploadCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -7,9 +7,21 @@ export const UploadCard = recipe({
 | 
			
		|||
    padding: config.space.S300,
 | 
			
		||||
    backgroundColor: color.SurfaceVariant.Container,
 | 
			
		||||
    color: color.SurfaceVariant.OnContainer,
 | 
			
		||||
    borderColor: color.SurfaceVariant.ContainerLine,
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    radii: RadiiVariant,
 | 
			
		||||
    outlined: {
 | 
			
		||||
      true: {
 | 
			
		||||
        borderStyle: 'solid',
 | 
			
		||||
        borderWidth: config.borderWidth.B300,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    compact: {
 | 
			
		||||
      true: {
 | 
			
		||||
        padding: config.space.S100,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  defaultVariants: {
 | 
			
		||||
    radii: '400',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,8 +12,13 @@ type UploadCardProps = {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>(
 | 
			
		||||
  ({ before, after, children, bottom, radii }, ref) => (
 | 
			
		||||
    <Box className={css.UploadCard({ radii })} direction="Column" gap="200" ref={ref}>
 | 
			
		||||
  ({ before, after, children, bottom, radii, outlined, compact }, ref) => (
 | 
			
		||||
    <Box
 | 
			
		||||
      className={css.UploadCard({ radii, outlined, compact })}
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      gap="200"
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    >
 | 
			
		||||
      <Box alignItems="Center" gap="200">
 | 
			
		||||
        {before}
 | 
			
		||||
        <Box alignItems="Center" grow="Yes" gap="200">
 | 
			
		||||
| 
						 | 
				
			
			@ -33,7 +38,7 @@ type UploadCardProgressProps = {
 | 
			
		|||
 | 
			
		||||
export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="200">
 | 
			
		||||
    <Box grow="Yes" direction="Column" gap="200">
 | 
			
		||||
      <ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
 | 
			
		||||
      <Box alignItems="Center" justifyContent="SpaceBetween">
 | 
			
		||||
        <Badge variant="Secondary" fill="Solid" radii="Pill">
 | 
			
		||||
| 
						 | 
				
			
			@ -49,6 +54,24 @@ export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgress
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function CompactUploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" gap="200" alignItems="Center">
 | 
			
		||||
      <Badge variant="Secondary" fill="Solid" radii="Pill">
 | 
			
		||||
        <Text size="L400">{`${Math.round(percent(0, totalBytes, sentBytes))}%`}</Text>
 | 
			
		||||
      </Badge>
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
        <ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Badge variant="Secondary" fill="Soft" radii="Pill">
 | 
			
		||||
        <Text size="L400">
 | 
			
		||||
          {bytesToSize(sentBytes)} / {bytesToSize(totalBytes)}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Badge>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UploadCardErrorProps = {
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,30 +1,26 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import React, { useEffect } from 'react';
 | 
			
		||||
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
 | 
			
		||||
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
 | 
			
		||||
import { TUploadAtom, UploadStatus, useBindUploadAtom } from '../../state/upload';
 | 
			
		||||
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { TUploadContent } from '../../utils/matrix';
 | 
			
		||||
import { getFileTypeIcon } from '../../utils/common';
 | 
			
		||||
 | 
			
		||||
type UploadCardRendererProps = {
 | 
			
		||||
  file: TUploadContent;
 | 
			
		||||
  isEncrypted?: boolean;
 | 
			
		||||
  uploadAtom: TUploadAtom;
 | 
			
		||||
  onRemove: (file: TUploadContent) => void;
 | 
			
		||||
  onComplete?: (upload: UploadSuccess) => void;
 | 
			
		||||
};
 | 
			
		||||
export function UploadCardRenderer({
 | 
			
		||||
  file,
 | 
			
		||||
  isEncrypted,
 | 
			
		||||
  uploadAtom,
 | 
			
		||||
  onRemove,
 | 
			
		||||
  onComplete,
 | 
			
		||||
}: UploadCardRendererProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const { upload, startUpload, cancelUpload } = useBindUploadAtom(
 | 
			
		||||
    mx,
 | 
			
		||||
    file,
 | 
			
		||||
    uploadAtom,
 | 
			
		||||
    isEncrypted
 | 
			
		||||
  );
 | 
			
		||||
  const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
 | 
			
		||||
  const { file } = upload;
 | 
			
		||||
 | 
			
		||||
  if (upload.status === UploadStatus.Idle) startUpload();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +29,12 @@ export function UploadCardRenderer({
 | 
			
		|||
    onRemove(file);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (upload.status === UploadStatus.Success) {
 | 
			
		||||
      onComplete?.(upload);
 | 
			
		||||
    }
 | 
			
		||||
  }, [upload, onComplete]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <UploadCard
 | 
			
		||||
      radii="300"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,2 +1,3 @@
 | 
			
		|||
export * from './UploadCard';
 | 
			
		||||
export * from './UploadCardRenderer';
 | 
			
		||||
export * from './CompactUploadCardRenderer';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,8 @@ import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
 | 
			
		|||
import { useVirtualizer } from '@tanstack/react-virtual';
 | 
			
		||||
import { useAtom, useAtomValue } from 'jotai';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
 | 
			
		||||
import { useSpace } from '../../hooks/useSpace';
 | 
			
		||||
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
 | 
			
		||||
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
 | 
			
		||||
| 
						 | 
				
			
			@ -258,7 +259,7 @@ export function Lobby() {
 | 
			
		|||
        const joinRuleContent = getStateEvent(
 | 
			
		||||
          itemRoom,
 | 
			
		||||
          StateEvent.RoomJoinRules
 | 
			
		||||
        )?.getContent<IJoinRuleEventContent>();
 | 
			
		||||
        )?.getContent<RoomJoinRulesEventContent>();
 | 
			
		||||
 | 
			
		||||
        if (joinRuleContent) {
 | 
			
		||||
          const allow =
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -56,7 +56,13 @@ import {
 | 
			
		|||
} from '../../components/editor';
 | 
			
		||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
 | 
			
		||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
			
		||||
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
 | 
			
		||||
import {
 | 
			
		||||
  TUploadContent,
 | 
			
		||||
  encryptFile,
 | 
			
		||||
  getImageInfo,
 | 
			
		||||
  getMxIdLocalPart,
 | 
			
		||||
  mxcUrlToHttp,
 | 
			
		||||
} from '../../utils/matrix';
 | 
			
		||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
 | 
			
		||||
import { useFilePicker } from '../../hooks/useFilePicker';
 | 
			
		||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
 | 
			
		||||
| 
						 | 
				
			
			@ -157,7 +163,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 | 
			
		|||
        const safeFiles = files.map(safeFile);
 | 
			
		||||
        const fileItems: TUploadItem[] = [];
 | 
			
		||||
 | 
			
		||||
        if (mx.isRoomEncrypted(roomId)) {
 | 
			
		||||
        if (room.hasEncryptionStateEvent()) {
 | 
			
		||||
          const encryptFiles = fulfilledPromiseSettledResult(
 | 
			
		||||
            await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
 | 
			
		||||
          );
 | 
			
		||||
| 
						 | 
				
			
			@ -172,7 +178,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 | 
			
		|||
          item: fileItems,
 | 
			
		||||
        });
 | 
			
		||||
      },
 | 
			
		||||
      [setSelectedFiles, roomId, mx]
 | 
			
		||||
      [setSelectedFiles, room]
 | 
			
		||||
    );
 | 
			
		||||
    const pickFile = useFilePicker(handleFiles, true);
 | 
			
		||||
    const handlePaste = useFilePasteHandler(handleFiles);
 | 
			
		||||
| 
						 | 
				
			
			@ -413,7 +419,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 | 
			
		|||
                      <UploadCardRenderer
 | 
			
		||||
                        // eslint-disable-next-line react/no-array-index-key
 | 
			
		||||
                        key={index}
 | 
			
		||||
                        file={fileItem.file}
 | 
			
		||||
                        isEncrypted={!!fileItem.encInfo}
 | 
			
		||||
                        uploadAtom={roomUploadAtomFamily(fileItem.file)}
 | 
			
		||||
                        onRemove={handleRemoveUpload}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -85,7 +85,7 @@ import {
 | 
			
		|||
  reactionOrEditEvent,
 | 
			
		||||
} from '../../utils/room';
 | 
			
		||||
import { useSetting } from '../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../state/settings';
 | 
			
		||||
import { MessageLayout, settingsAtom } from '../../state/settings';
 | 
			
		||||
import { openProfileViewer } from '../../../client/action/navigation';
 | 
			
		||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
 | 
			
		||||
import { Reactions, Message, Event, EncryptedContent } from './message';
 | 
			
		||||
| 
						 | 
				
			
			@ -336,7 +336,10 @@ const useTimelinePagination = (
 | 
			
		|||
          backwards ? Direction.Backward : Direction.Forward
 | 
			
		||||
        ) ?? timelineToPaginate;
 | 
			
		||||
      // Decrypt all event ahead of render cycle
 | 
			
		||||
      if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
 | 
			
		||||
      const roomId = fetchedTimeline.getRoomId();
 | 
			
		||||
      const room = roomId ? mx.getRoom(roomId) : null;
 | 
			
		||||
 | 
			
		||||
      if (room?.hasEncryptionStateEvent()) {
 | 
			
		||||
        await to(decryptAllTimelineEvent(mx, fetchedTimeline));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -421,7 +424,6 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
 | 
			
		|||
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const encryptedRoom = mx.isRoomEncrypted(room.roomId);
 | 
			
		||||
  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
 | 
			
		||||
  const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
 | 
			
		||||
  const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
 | 
			
		||||
| 
						 | 
				
			
			@ -429,7 +431,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
 | 
			
		||||
  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
 | 
			
		||||
  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
 | 
			
		||||
  const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
 | 
			
		||||
  const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
 | 
			
		||||
  const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
 | 
			
		||||
  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
 | 
			
		||||
  const powerLevels = usePowerLevelsContext();
 | 
			
		||||
| 
						 | 
				
			
			@ -1030,7 +1032,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
                urlPreview={showUrlPreview}
 | 
			
		||||
                htmlReactParserOptions={htmlReactParserOptions}
 | 
			
		||||
                linkifyOpts={linkifyOpts}
 | 
			
		||||
                outlineAttachment={messageLayout === 2}
 | 
			
		||||
                outlineAttachment={messageLayout === MessageLayout.Bubble}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
          </Message>
 | 
			
		||||
| 
						 | 
				
			
			@ -1126,7 +1128,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
                      urlPreview={showUrlPreview}
 | 
			
		||||
                      htmlReactParserOptions={htmlReactParserOptions}
 | 
			
		||||
                      linkifyOpts={linkifyOpts}
 | 
			
		||||
                      outlineAttachment={messageLayout === 2}
 | 
			
		||||
                      outlineAttachment={messageLayout === MessageLayout.Bubble}
 | 
			
		||||
                    />
 | 
			
		||||
                  );
 | 
			
		||||
                }
 | 
			
		||||
| 
						 | 
				
			
			@ -1211,7 +1213,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
        const highlighted = focusItem?.index === item && focusItem.highlight;
 | 
			
		||||
        const parsed = parseMemberEvent(mEvent);
 | 
			
		||||
 | 
			
		||||
        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
 | 
			
		||||
        const timeJSX = (
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
          <Event
 | 
			
		||||
| 
						 | 
				
			
			@ -1244,7 +1248,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
        const senderId = mEvent.getSender() ?? '';
 | 
			
		||||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
 | 
			
		||||
        const timeJSX = (
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
          <Event
 | 
			
		||||
| 
						 | 
				
			
			@ -1278,7 +1284,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
        const senderId = mEvent.getSender() ?? '';
 | 
			
		||||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
 | 
			
		||||
        const timeJSX = (
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
          <Event
 | 
			
		||||
| 
						 | 
				
			
			@ -1312,7 +1320,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
        const senderId = mEvent.getSender() ?? '';
 | 
			
		||||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
 | 
			
		||||
        const timeJSX = (
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
          <Event
 | 
			
		||||
| 
						 | 
				
			
			@ -1348,7 +1358,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
      const senderId = mEvent.getSender() ?? '';
 | 
			
		||||
      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
 | 
			
		||||
      const timeJSX = (
 | 
			
		||||
        <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
        <Event
 | 
			
		||||
| 
						 | 
				
			
			@ -1389,7 +1401,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
      const senderId = mEvent.getSender() ?? '';
 | 
			
		||||
      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
 | 
			
		||||
      const timeJSX = (
 | 
			
		||||
        <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
        <Event
 | 
			
		||||
| 
						 | 
				
			
			@ -1544,7 +1558,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
            <div
 | 
			
		||||
              style={{
 | 
			
		||||
                padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
 | 
			
		||||
                  messageLayout === 1 ? config.space.S400 : toRem(64)
 | 
			
		||||
                  messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
 | 
			
		||||
                }`,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
| 
						 | 
				
			
			@ -1552,7 +1566,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
          {(canPaginateBack || !rangeAtStart) &&
 | 
			
		||||
            (messageLayout === 1 ? (
 | 
			
		||||
            (messageLayout === MessageLayout.Compact ? (
 | 
			
		||||
              <>
 | 
			
		||||
                <MessageBase>
 | 
			
		||||
                  <CompactPlaceholder key={getItems().length} />
 | 
			
		||||
| 
						 | 
				
			
			@ -1587,7 +1601,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
			
		|||
          {getItems().map(eventRenderer)}
 | 
			
		||||
 | 
			
		||||
          {(!liveTimelineLinked || !rangeAtEnd) &&
 | 
			
		||||
            (messageLayout === 1 ? (
 | 
			
		||||
            (messageLayout === MessageLayout.Compact ? (
 | 
			
		||||
              <>
 | 
			
		||||
                <MessageBase ref={observeFrontAnchor}>
 | 
			
		||||
                  <CompactPlaceholder key={getItems().length} />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -716,7 +716,7 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
    const headerJSX = !collapse && (
 | 
			
		||||
      <Box
 | 
			
		||||
        gap="300"
 | 
			
		||||
        direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
 | 
			
		||||
        direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
 | 
			
		||||
        justifyContent="SpaceBetween"
 | 
			
		||||
        alignItems="Baseline"
 | 
			
		||||
        grow="Yes"
 | 
			
		||||
| 
						 | 
				
			
			@ -728,12 +728,12 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
          onContextMenu={onUserClick}
 | 
			
		||||
          onClick={onUsernameClick}
 | 
			
		||||
        >
 | 
			
		||||
          <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
 | 
			
		||||
          <Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate>
 | 
			
		||||
            <b>{senderDisplayName}</b>
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Username>
 | 
			
		||||
        <Box shrink="No" gap="100">
 | 
			
		||||
          {messageLayout === 0 && hover && (
 | 
			
		||||
          {messageLayout === MessageLayout.Modern && hover && (
 | 
			
		||||
            <>
 | 
			
		||||
              <Text as="span" size="T200" priority="300">
 | 
			
		||||
                {senderId}
 | 
			
		||||
| 
						 | 
				
			
			@ -743,12 +743,12 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
              </Text>
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const avatarJSX = !collapse && messageLayout !== 1 && (
 | 
			
		||||
    const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
 | 
			
		||||
      <AvatarBase>
 | 
			
		||||
        <Avatar
 | 
			
		||||
          className={css.MessageAvatar}
 | 
			
		||||
| 
						 | 
				
			
			@ -1043,18 +1043,18 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
            </Menu>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        {messageLayout === 1 && (
 | 
			
		||||
        {messageLayout === MessageLayout.Compact && (
 | 
			
		||||
          <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
            {msgContentJSX}
 | 
			
		||||
          </CompactLayout>
 | 
			
		||||
        )}
 | 
			
		||||
        {messageLayout === 2 && (
 | 
			
		||||
        {messageLayout === MessageLayout.Bubble && (
 | 
			
		||||
          <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
            {headerJSX}
 | 
			
		||||
            {msgContentJSX}
 | 
			
		||||
          </BubbleLayout>
 | 
			
		||||
        )}
 | 
			
		||||
        {messageLayout !== 1 && messageLayout !== 2 && (
 | 
			
		||||
        {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
 | 
			
		||||
          <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
			
		||||
            {headerJSX}
 | 
			
		||||
            {msgContentJSX}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										234
									
								
								src/app/features/settings/Settings.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								src/app/features/settings/Settings.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,234 @@
 | 
			
		|||
import React, { useMemo, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  IconSrc,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Text,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { General } from './general';
 | 
			
		||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
 | 
			
		||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
 | 
			
		||||
import { Account } from './account';
 | 
			
		||||
import { useUserProfile } from '../../hooks/useUserProfile';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
			
		||||
import { UserAvatar } from '../../components/user-avatar';
 | 
			
		||||
import { nameInitials } from '../../utils/common';
 | 
			
		||||
import { Notifications } from './notifications';
 | 
			
		||||
import { Devices } from './devices';
 | 
			
		||||
import { EmojisStickers } from './emojis-stickers';
 | 
			
		||||
import { DeveloperTools } from './developer-tools';
 | 
			
		||||
import { About } from './about';
 | 
			
		||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
import { LogoutDialog } from '../../components/LogoutDialog';
 | 
			
		||||
 | 
			
		||||
export enum SettingsPages {
 | 
			
		||||
  GeneralPage,
 | 
			
		||||
  AccountPage,
 | 
			
		||||
  NotificationPage,
 | 
			
		||||
  DevicesPage,
 | 
			
		||||
  EmojisStickersPage,
 | 
			
		||||
  DeveloperToolsPage,
 | 
			
		||||
  AboutPage,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type SettingsMenuItem = {
 | 
			
		||||
  page: SettingsPages;
 | 
			
		||||
  name: string;
 | 
			
		||||
  icon: IconSrc;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const useSettingsMenuItems = (): SettingsMenuItem[] =>
 | 
			
		||||
  useMemo(
 | 
			
		||||
    () => [
 | 
			
		||||
      {
 | 
			
		||||
        page: SettingsPages.GeneralPage,
 | 
			
		||||
        name: 'General',
 | 
			
		||||
        icon: Icons.Setting,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        page: SettingsPages.AccountPage,
 | 
			
		||||
        name: 'Account',
 | 
			
		||||
        icon: Icons.User,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        page: SettingsPages.NotificationPage,
 | 
			
		||||
        name: 'Notifications',
 | 
			
		||||
        icon: Icons.Bell,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        page: SettingsPages.DevicesPage,
 | 
			
		||||
        name: 'Devices',
 | 
			
		||||
        icon: Icons.Category,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        page: SettingsPages.EmojisStickersPage,
 | 
			
		||||
        name: 'Emojis & Stickers',
 | 
			
		||||
        icon: Icons.Smile,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        page: SettingsPages.DeveloperToolsPage,
 | 
			
		||||
        name: 'Developer Tools',
 | 
			
		||||
        icon: Icons.Terminal,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        page: SettingsPages.AboutPage,
 | 
			
		||||
        name: 'About',
 | 
			
		||||
        icon: Icons.Info,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
type SettingsProps = {
 | 
			
		||||
  initialPage?: SettingsPages;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function Settings({ initialPage, requestClose }: SettingsProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
  const profile = useUserProfile(userId);
 | 
			
		||||
  const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const avatarUrl = profile.avatarUrl
 | 
			
		||||
    ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
  const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
 | 
			
		||||
    if (initialPage) return initialPage;
 | 
			
		||||
    return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
 | 
			
		||||
  });
 | 
			
		||||
  const menuItems = useSettingsMenuItems();
 | 
			
		||||
 | 
			
		||||
  const handlePageRequestClose = () => {
 | 
			
		||||
    if (screenSize === ScreenSize.Mobile) {
 | 
			
		||||
      setActivePage(undefined);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    requestClose();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <PageRoot
 | 
			
		||||
      nav={
 | 
			
		||||
        screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
 | 
			
		||||
          <PageNav size="300">
 | 
			
		||||
            <PageNavHeader outlined={false}>
 | 
			
		||||
              <Box grow="Yes" gap="200">
 | 
			
		||||
                <Avatar size="200" radii="300">
 | 
			
		||||
                  <UserAvatar
 | 
			
		||||
                    userId={userId}
 | 
			
		||||
                    src={avatarUrl}
 | 
			
		||||
                    renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
 | 
			
		||||
                  />
 | 
			
		||||
                </Avatar>
 | 
			
		||||
                <Text size="H4" truncate>
 | 
			
		||||
                  Settings
 | 
			
		||||
                </Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box shrink="No">
 | 
			
		||||
                {screenSize === ScreenSize.Mobile && (
 | 
			
		||||
                  <IconButton onClick={requestClose} variant="Background">
 | 
			
		||||
                    <Icon src={Icons.Cross} />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )}
 | 
			
		||||
              </Box>
 | 
			
		||||
            </PageNavHeader>
 | 
			
		||||
            <Box grow="Yes" direction="Column">
 | 
			
		||||
              <PageNavContent>
 | 
			
		||||
                <div style={{ flexGrow: 1 }}>
 | 
			
		||||
                  {menuItems.map((item) => (
 | 
			
		||||
                    <MenuItem
 | 
			
		||||
                      key={item.name}
 | 
			
		||||
                      variant="Background"
 | 
			
		||||
                      radii="400"
 | 
			
		||||
                      aria-pressed={activePage === item.page}
 | 
			
		||||
                      before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
 | 
			
		||||
                      onClick={() => setActivePage(item.page)}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text
 | 
			
		||||
                        style={{
 | 
			
		||||
                          fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
 | 
			
		||||
                        }}
 | 
			
		||||
                        size="T300"
 | 
			
		||||
                        truncate
 | 
			
		||||
                      >
 | 
			
		||||
                        {item.name}
 | 
			
		||||
                      </Text>
 | 
			
		||||
                    </MenuItem>
 | 
			
		||||
                  ))}
 | 
			
		||||
                </div>
 | 
			
		||||
              </PageNavContent>
 | 
			
		||||
              <Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
 | 
			
		||||
                <UseStateProvider initial={false}>
 | 
			
		||||
                  {(logout, setLogout) => (
 | 
			
		||||
                    <>
 | 
			
		||||
                      <Button
 | 
			
		||||
                        size="300"
 | 
			
		||||
                        variant="Critical"
 | 
			
		||||
                        fill="None"
 | 
			
		||||
                        radii="Pill"
 | 
			
		||||
                        before={<Icon src={Icons.Power} size="100" />}
 | 
			
		||||
                        onClick={() => setLogout(true)}
 | 
			
		||||
                      >
 | 
			
		||||
                        <Text size="B400">Logout</Text>
 | 
			
		||||
                      </Button>
 | 
			
		||||
                      {logout && (
 | 
			
		||||
                        <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
                          <OverlayCenter>
 | 
			
		||||
                            <FocusTrap
 | 
			
		||||
                              focusTrapOptions={{
 | 
			
		||||
                                onDeactivate: () => setLogout(false),
 | 
			
		||||
                                clickOutsideDeactivates: true,
 | 
			
		||||
                                escapeDeactivates: stopPropagation,
 | 
			
		||||
                              }}
 | 
			
		||||
                            >
 | 
			
		||||
                              <LogoutDialog handleClose={() => setLogout(false)} />
 | 
			
		||||
                            </FocusTrap>
 | 
			
		||||
                          </OverlayCenter>
 | 
			
		||||
                        </Overlay>
 | 
			
		||||
                      )}
 | 
			
		||||
                    </>
 | 
			
		||||
                  )}
 | 
			
		||||
                </UseStateProvider>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageNav>
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {activePage === SettingsPages.GeneralPage && (
 | 
			
		||||
        <General requestClose={handlePageRequestClose} />
 | 
			
		||||
      )}
 | 
			
		||||
      {activePage === SettingsPages.AccountPage && (
 | 
			
		||||
        <Account requestClose={handlePageRequestClose} />
 | 
			
		||||
      )}
 | 
			
		||||
      {activePage === SettingsPages.NotificationPage && (
 | 
			
		||||
        <Notifications requestClose={handlePageRequestClose} />
 | 
			
		||||
      )}
 | 
			
		||||
      {activePage === SettingsPages.DevicesPage && (
 | 
			
		||||
        <Devices requestClose={handlePageRequestClose} />
 | 
			
		||||
      )}
 | 
			
		||||
      {activePage === SettingsPages.EmojisStickersPage && (
 | 
			
		||||
        <EmojisStickers requestClose={handlePageRequestClose} />
 | 
			
		||||
      )}
 | 
			
		||||
      {activePage === SettingsPages.DeveloperToolsPage && (
 | 
			
		||||
        <DeveloperTools requestClose={handlePageRequestClose} />
 | 
			
		||||
      )}
 | 
			
		||||
      {activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
 | 
			
		||||
    </PageRoot>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										245
									
								
								src/app/features/settings/about/About.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								src/app/features/settings/about/About.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,245 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem } from 'folds';
 | 
			
		||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
 | 
			
		||||
import cons from '../../../../client/state/cons';
 | 
			
		||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
 | 
			
		||||
type AboutProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function About({ requestClose }: AboutProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false}>
 | 
			
		||||
        <Box grow="Yes" gap="200">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Text size="H3" truncate>
 | 
			
		||||
              About
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <Box gap="400">
 | 
			
		||||
                <Box shrink="No">
 | 
			
		||||
                  <img
 | 
			
		||||
                    style={{ width: toRem(60), height: toRem(60) }}
 | 
			
		||||
                    src={CinnySVG}
 | 
			
		||||
                    alt="Cinny logo"
 | 
			
		||||
                  />
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Box direction="Column" gap="300">
 | 
			
		||||
                  <Box direction="Column" gap="100">
 | 
			
		||||
                    <Box gap="100" alignItems="End">
 | 
			
		||||
                      <Text size="H3">Cinny</Text>
 | 
			
		||||
                      <Text size="T200">v{cons.version}</Text>
 | 
			
		||||
                    </Box>
 | 
			
		||||
                    <Text>Yet another matrix client.</Text>
 | 
			
		||||
                  </Box>
 | 
			
		||||
 | 
			
		||||
                  <Box gap="200" wrap="Wrap">
 | 
			
		||||
                    <Button
 | 
			
		||||
                      as="a"
 | 
			
		||||
                      href="https://github.com/cinnyapp/cinny"
 | 
			
		||||
                      rel="noreferrer noopener"
 | 
			
		||||
                      target="_blank"
 | 
			
		||||
                      variant="Secondary"
 | 
			
		||||
                      fill="Soft"
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      before={<Icon src={Icons.Code} size="100" filled />}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Source Code</Text>
 | 
			
		||||
                    </Button>
 | 
			
		||||
                    <Button
 | 
			
		||||
                      as="a"
 | 
			
		||||
                      href="https://cinny.in/#sponsor"
 | 
			
		||||
                      rel="noreferrer noopener"
 | 
			
		||||
                      target="_blank"
 | 
			
		||||
                      variant="Critical"
 | 
			
		||||
                      fill="Soft"
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      before={<Icon src={Icons.Heart} size="100" filled />}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Support</Text>
 | 
			
		||||
                    </Button>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Options</Text>
 | 
			
		||||
                <SequenceCard
 | 
			
		||||
                  className={SequenceCardStyle}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  direction="Column"
 | 
			
		||||
                  gap="400"
 | 
			
		||||
                >
 | 
			
		||||
                  <SettingTile
 | 
			
		||||
                    title="Clear Cache & Reload"
 | 
			
		||||
                    description="Clear all your locally stored data and reload from server."
 | 
			
		||||
                    after={
 | 
			
		||||
                      <Button
 | 
			
		||||
                        onClick={() => clearCacheAndReload(mx)}
 | 
			
		||||
                        variant="Secondary"
 | 
			
		||||
                        fill="Soft"
 | 
			
		||||
                        size="300"
 | 
			
		||||
                        radii="300"
 | 
			
		||||
                        outlined
 | 
			
		||||
                      >
 | 
			
		||||
                        <Text size="B300">Clear Cache</Text>
 | 
			
		||||
                      </Button>
 | 
			
		||||
                    }
 | 
			
		||||
                  />
 | 
			
		||||
                </SequenceCard>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Credits</Text>
 | 
			
		||||
                <SequenceCard
 | 
			
		||||
                  className={SequenceCardStyle}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  direction="Column"
 | 
			
		||||
                  gap="400"
 | 
			
		||||
                >
 | 
			
		||||
                  <Box
 | 
			
		||||
                    as="ul"
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="200"
 | 
			
		||||
                    style={{
 | 
			
		||||
                      margin: 0,
 | 
			
		||||
                      paddingLeft: config.space.S400,
 | 
			
		||||
                    }}
 | 
			
		||||
                  >
 | 
			
		||||
                    <li>
 | 
			
		||||
                      <Text size="T300">
 | 
			
		||||
                        The{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="https://github.com/matrix-org/matrix-js-sdk"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                        >
 | 
			
		||||
                          matrix-js-sdk
 | 
			
		||||
                        </a>{' '}
 | 
			
		||||
                        is ©{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="https://matrix.org/foundation"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                        >
 | 
			
		||||
                          The Matrix.org Foundation C.I.C
 | 
			
		||||
                        </a>{' '}
 | 
			
		||||
                        used under the terms of{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="http://www.apache.org/licenses/LICENSE-2.0"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                        >
 | 
			
		||||
                          Apache 2.0
 | 
			
		||||
                        </a>
 | 
			
		||||
                        .
 | 
			
		||||
                      </Text>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li>
 | 
			
		||||
                      <Text size="T300">
 | 
			
		||||
                        The{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="https://github.com/mozilla/twemoji-colr"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                        >
 | 
			
		||||
                          twemoji-colr
 | 
			
		||||
                        </a>{' '}
 | 
			
		||||
                        font is ©{' '}
 | 
			
		||||
                        <a href="https://mozilla.org/" target="_blank" rel="noreferrer noopener">
 | 
			
		||||
                          Mozilla Foundation
 | 
			
		||||
                        </a>{' '}
 | 
			
		||||
                        used under the terms of{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="http://www.apache.org/licenses/LICENSE-2.0"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                        >
 | 
			
		||||
                          Apache 2.0
 | 
			
		||||
                        </a>
 | 
			
		||||
                        .
 | 
			
		||||
                      </Text>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li>
 | 
			
		||||
                      <Text size="T300">
 | 
			
		||||
                        The{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="https://twemoji.twitter.com"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                        >
 | 
			
		||||
                          Twemoji
 | 
			
		||||
                        </a>{' '}
 | 
			
		||||
                        emoji art is ©{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="https://twemoji.twitter.com"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                        >
 | 
			
		||||
                          Twitter, Inc and other contributors
 | 
			
		||||
                        </a>{' '}
 | 
			
		||||
                        used under the terms of{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="https://creativecommons.org/licenses/by/4.0/"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                        >
 | 
			
		||||
                          CC-BY 4.0
 | 
			
		||||
                        </a>
 | 
			
		||||
                        .
 | 
			
		||||
                      </Text>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li>
 | 
			
		||||
                      <Text size="T300">
 | 
			
		||||
                        The{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="https://material.io/design/sound/sound-resources.html"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                        >
 | 
			
		||||
                          Material sound resources
 | 
			
		||||
                        </a>{' '}
 | 
			
		||||
                        are ©{' '}
 | 
			
		||||
                        <a href="https://google.com" target="_blank" rel="noreferrer noopener">
 | 
			
		||||
                          Google
 | 
			
		||||
                        </a>{' '}
 | 
			
		||||
                        used under the terms of{' '}
 | 
			
		||||
                        <a
 | 
			
		||||
                          href="https://creativecommons.org/licenses/by/4.0/"
 | 
			
		||||
                          target="_blank"
 | 
			
		||||
                          rel="noreferrer noopener"
 | 
			
		||||
                        >
 | 
			
		||||
                          CC-BY 4.0
 | 
			
		||||
                        </a>
 | 
			
		||||
                        .
 | 
			
		||||
                      </Text>
 | 
			
		||||
                    </li>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </SequenceCard>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/settings/about/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/settings/about/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './About';
 | 
			
		||||
							
								
								
									
										428
									
								
								src/app/features/settings/account/Account.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										428
									
								
								src/app/features/settings/account/Account.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,428 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  ChangeEventHandler,
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Input,
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Modal,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
 | 
			
		||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { UserAvatar } from '../../../components/user-avatar';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { nameInitials } from '../../../utils/common';
 | 
			
		||||
import { copyToClipboard } from '../../../utils/dom';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useFilePicker } from '../../../hooks/useFilePicker';
 | 
			
		||||
import { useObjectURL } from '../../../hooks/useObjectURL';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { ImageEditor } from '../../../components/image-editor';
 | 
			
		||||
import { ModalWide } from '../../../styles/Modal.css';
 | 
			
		||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
 | 
			
		||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
 | 
			
		||||
import { useCapabilities } from '../../../hooks/useCapabilities';
 | 
			
		||||
 | 
			
		||||
function MatrixId() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Matrix ID</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title={userId}
 | 
			
		||||
          after={
 | 
			
		||||
            <Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
 | 
			
		||||
              <Text size="T200">Copy</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ProfileProps = {
 | 
			
		||||
  profile: UserProfile;
 | 
			
		||||
  userId: string;
 | 
			
		||||
};
 | 
			
		||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const [alertRemove, setAlertRemove] = useState(false);
 | 
			
		||||
  const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const avatarUrl = profile.avatarUrl
 | 
			
		||||
    ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
 | 
			
		||||
  const [imageFile, setImageFile] = useState<File>();
 | 
			
		||||
  const imageFileURL = useObjectURL(imageFile);
 | 
			
		||||
  const uploadAtom = useMemo(() => {
 | 
			
		||||
    if (imageFile) return createUploadAtom(imageFile);
 | 
			
		||||
    return undefined;
 | 
			
		||||
  }, [imageFile]);
 | 
			
		||||
 | 
			
		||||
  const pickFile = useFilePicker(setImageFile, false);
 | 
			
		||||
 | 
			
		||||
  const handleRemoveUpload = useCallback(() => {
 | 
			
		||||
    setImageFile(undefined);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleUploaded = useCallback(
 | 
			
		||||
    (upload: UploadSuccess) => {
 | 
			
		||||
      const { mxc } = upload;
 | 
			
		||||
      mx.setAvatarUrl(mxc);
 | 
			
		||||
      handleRemoveUpload();
 | 
			
		||||
    },
 | 
			
		||||
    [mx, handleRemoveUpload]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRemoveAvatar = () => {
 | 
			
		||||
    mx.setAvatarUrl('');
 | 
			
		||||
    setAlertRemove(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Avatar
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
      after={
 | 
			
		||||
        <Avatar size="500" radii="300">
 | 
			
		||||
          <UserAvatar
 | 
			
		||||
            userId={userId}
 | 
			
		||||
            src={avatarUrl}
 | 
			
		||||
            renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
 | 
			
		||||
          />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {uploadAtom ? (
 | 
			
		||||
        <Box gap="200" direction="Column">
 | 
			
		||||
          <CompactUploadCardRenderer
 | 
			
		||||
            uploadAtom={uploadAtom}
 | 
			
		||||
            onRemove={handleRemoveUpload}
 | 
			
		||||
            onComplete={handleUploaded}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Box gap="200">
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={() => pickFile('image/*')}
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={disableSetAvatar}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Upload</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
          {avatarUrl && (
 | 
			
		||||
            <Button
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              disabled={disableSetAvatar}
 | 
			
		||||
              onClick={() => setAlertRemove(true)}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Remove</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {imageFileURL && (
 | 
			
		||||
        <Overlay open={false} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                onDeactivate: handleRemoveUpload,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal className={ModalWide} variant="Surface" size="500">
 | 
			
		||||
                <ImageEditor
 | 
			
		||||
                  name={imageFile?.name ?? 'Unnamed'}
 | 
			
		||||
                  url={imageFileURL}
 | 
			
		||||
                  requestClose={handleRemoveUpload}
 | 
			
		||||
                />
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setAlertRemove(false),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Dialog variant="Surface">
 | 
			
		||||
              <Header
 | 
			
		||||
                style={{
 | 
			
		||||
                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
                  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
                }}
 | 
			
		||||
                variant="Surface"
 | 
			
		||||
                size="500"
 | 
			
		||||
              >
 | 
			
		||||
                <Box grow="Yes">
 | 
			
		||||
                  <Text size="H4">Remove Avatar</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
 | 
			
		||||
                  <Icon src={Icons.Cross} />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Header>
 | 
			
		||||
              <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
                <Box direction="Column" gap="200">
 | 
			
		||||
                  <Text priority="400">Are you sure you want to remove profile avatar?</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Button variant="Critical" onClick={handleRemoveAvatar}>
 | 
			
		||||
                  <Text size="B400">Remove</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Dialog>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const [displayName, setDisplayName] = useState<string>();
 | 
			
		||||
 | 
			
		||||
  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 = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function Account({ requestClose }: AccountProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false}>
 | 
			
		||||
        <Box grow="Yes" gap="200">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Text size="H3" truncate>
 | 
			
		||||
              Account
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <Profile />
 | 
			
		||||
              <MatrixId />
 | 
			
		||||
              <ContactInformation />
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/settings/account/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/settings/account/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './Account';
 | 
			
		||||
							
								
								
									
										211
									
								
								src/app/features/settings/developer-tools/AccountDataEditor.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										211
									
								
								src/app/features/settings/developer-tools/AccountDataEditor.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,211 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  KeyboardEventHandler,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  as,
 | 
			
		||||
  Box,
 | 
			
		||||
  Header,
 | 
			
		||||
  Text,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Input,
 | 
			
		||||
  Button,
 | 
			
		||||
  TextArea as TextAreaComponent,
 | 
			
		||||
  color,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { isKeyHotkey } from 'is-hotkey';
 | 
			
		||||
import { MatrixError } from 'matrix-js-sdk';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
import { useTextAreaIntentHandler } from '../../../hooks/useTextAreaIntent';
 | 
			
		||||
import { Cursor, Intent, TextArea, TextAreaOperations } from '../../../plugins/text-area';
 | 
			
		||||
import { GetTarget } from '../../../plugins/text-area/type';
 | 
			
		||||
import { syntaxErrorPosition } from '../../../utils/dom';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
 | 
			
		||||
const EDITOR_INTENT_SPACE_COUNT = 2;
 | 
			
		||||
 | 
			
		||||
export type AccountDataEditorProps = {
 | 
			
		||||
  type?: string;
 | 
			
		||||
  content?: object;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const AccountDataEditor = as<'div', AccountDataEditorProps>(
 | 
			
		||||
  ({ type, content, requestClose, ...props }, ref) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const defaultContent = useMemo(
 | 
			
		||||
      () => JSON.stringify(content, null, EDITOR_INTENT_SPACE_COUNT),
 | 
			
		||||
      [content]
 | 
			
		||||
    );
 | 
			
		||||
    const textAreaRef = useRef<HTMLTextAreaElement>(null);
 | 
			
		||||
    const [jsonError, setJSONError] = useState<SyntaxError>();
 | 
			
		||||
 | 
			
		||||
    const getTarget: GetTarget = useCallback(() => {
 | 
			
		||||
      const target = textAreaRef.current;
 | 
			
		||||
      if (!target) throw new Error('TextArea element not found!');
 | 
			
		||||
      return target;
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const { textArea, operations, intent } = useMemo(() => {
 | 
			
		||||
      const ta = new TextArea(getTarget);
 | 
			
		||||
      const op = new TextAreaOperations(getTarget);
 | 
			
		||||
      return {
 | 
			
		||||
        textArea: ta,
 | 
			
		||||
        operations: op,
 | 
			
		||||
        intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
 | 
			
		||||
      };
 | 
			
		||||
    }, [getTarget]);
 | 
			
		||||
 | 
			
		||||
    const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
 | 
			
		||||
 | 
			
		||||
    const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
 | 
			
		||||
      intentHandler(evt);
 | 
			
		||||
      if (isKeyHotkey('escape', evt)) {
 | 
			
		||||
        const cursor = Cursor.fromTextAreaElement(getTarget());
 | 
			
		||||
        operations.deselect(cursor);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
 | 
			
		||||
      useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
 | 
			
		||||
    );
 | 
			
		||||
    const submitting = submitState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
    const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
      evt.preventDefault();
 | 
			
		||||
      if (submitting) return;
 | 
			
		||||
 | 
			
		||||
      const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
      const typeInput = target?.typeInput as HTMLInputElement | undefined;
 | 
			
		||||
      const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
 | 
			
		||||
      if (!typeInput || !contentTextArea) return;
 | 
			
		||||
 | 
			
		||||
      const typeStr = typeInput.value.trim();
 | 
			
		||||
      const contentStr = contentTextArea.value.trim();
 | 
			
		||||
 | 
			
		||||
      let parsedContent: object;
 | 
			
		||||
      try {
 | 
			
		||||
        parsedContent = JSON.parse(contentStr);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        setJSONError(e as SyntaxError);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      setJSONError(undefined);
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        !typeStr ||
 | 
			
		||||
        parsedContent === null ||
 | 
			
		||||
        defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
 | 
			
		||||
      ) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      submit(typeStr, parsedContent);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      if (jsonError) {
 | 
			
		||||
        const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
 | 
			
		||||
        const cursor = new Cursor(errorPosition, errorPosition, 'none');
 | 
			
		||||
        operations.select(cursor);
 | 
			
		||||
        getTarget()?.focus();
 | 
			
		||||
      }
 | 
			
		||||
    }, [jsonError, operations, getTarget]);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      if (submitState.status === AsyncStatus.Success) {
 | 
			
		||||
        requestClose();
 | 
			
		||||
      }
 | 
			
		||||
    }, [submitState, requestClose]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Box grow="Yes" direction="Column" {...props} ref={ref}>
 | 
			
		||||
        <Header className={css.EditorHeader} size="600">
 | 
			
		||||
          <Box grow="Yes" gap="200">
 | 
			
		||||
            <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
              <Text size="H3" truncate>
 | 
			
		||||
                Account Data
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
            <Box shrink="No">
 | 
			
		||||
              <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
                <Icon src={Icons.Cross} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Header>
 | 
			
		||||
        <Box
 | 
			
		||||
          as="form"
 | 
			
		||||
          onSubmit={handleSubmit}
 | 
			
		||||
          grow="Yes"
 | 
			
		||||
          className={css.EditorContent}
 | 
			
		||||
          direction="Column"
 | 
			
		||||
          gap="400"
 | 
			
		||||
          aria-disabled={submitting}
 | 
			
		||||
        >
 | 
			
		||||
          <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
            <Text size="L400">Type</Text>
 | 
			
		||||
            <Box gap="300">
 | 
			
		||||
              <Box grow="Yes" direction="Column">
 | 
			
		||||
                <Input
 | 
			
		||||
                  name="typeInput"
 | 
			
		||||
                  size="400"
 | 
			
		||||
                  readOnly={!!type || submitting}
 | 
			
		||||
                  defaultValue={type}
 | 
			
		||||
                  required
 | 
			
		||||
                />
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Button
 | 
			
		||||
                variant="Primary"
 | 
			
		||||
                size="400"
 | 
			
		||||
                type="submit"
 | 
			
		||||
                disabled={submitting}
 | 
			
		||||
                before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B400">Save</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </Box>
 | 
			
		||||
 | 
			
		||||
            {submitState.status === AsyncStatus.Error && (
 | 
			
		||||
              <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
                <b>{submitState.error.message}</b>
 | 
			
		||||
              </Text>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
            <Box shrink="No">
 | 
			
		||||
              <Text size="L400">JSON Content</Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
            <TextAreaComponent
 | 
			
		||||
              ref={textAreaRef}
 | 
			
		||||
              name="contentTextArea"
 | 
			
		||||
              className={css.EditorTextArea}
 | 
			
		||||
              onKeyDown={handleKeyDown}
 | 
			
		||||
              defaultValue={defaultContent}
 | 
			
		||||
              resize="None"
 | 
			
		||||
              spellCheck="false"
 | 
			
		||||
              required
 | 
			
		||||
              readOnly={submitting}
 | 
			
		||||
            />
 | 
			
		||||
            {jsonError && (
 | 
			
		||||
              <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
                <b>
 | 
			
		||||
                  {jsonError.name}: {jsonError.message}
 | 
			
		||||
                </b>
 | 
			
		||||
              </Text>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										302
									
								
								src/app/features/settings/developer-tools/DevelopTools.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								src/app/features/settings/developer-tools/DevelopTools.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,302 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Switch,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Modal,
 | 
			
		||||
  Chip,
 | 
			
		||||
  Button,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Menu,
 | 
			
		||||
  config,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { MatrixEvent } from 'matrix-js-sdk';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useSetting } from '../../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../../state/settings';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
 | 
			
		||||
import { TextViewer } from '../../../components/text-viewer';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { AccountDataEditor } from './AccountDataEditor';
 | 
			
		||||
import { copyToClipboard } from '../../../utils/dom';
 | 
			
		||||
 | 
			
		||||
function AccountData() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [view, setView] = useState(false);
 | 
			
		||||
  const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
 | 
			
		||||
  const [selectedEvent, selectEvent] = useState<MatrixEvent>();
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
  const [selectedOption, selectOption] = useState<'edit' | 'inspect'>();
 | 
			
		||||
 | 
			
		||||
  useAccountDataCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      () => setAccountData(Array.from(mx.store.accountData.values())),
 | 
			
		||||
      [mx, setAccountData]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    const target = evt.currentTarget;
 | 
			
		||||
    const eventType = target.getAttribute('data-event-type');
 | 
			
		||||
    if (eventType) {
 | 
			
		||||
      const mEvent = accountData.find((mEvt) => mEvt.getType() === eventType);
 | 
			
		||||
      setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
      selectEvent(mEvent);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleMenuClose = () => setMenuCords(undefined);
 | 
			
		||||
 | 
			
		||||
  const handleEdit = () => {
 | 
			
		||||
    selectOption('edit');
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
  const handleInspect = () => {
 | 
			
		||||
    selectOption('inspect');
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
  const handleClose = useCallback(() => {
 | 
			
		||||
    selectEvent(undefined);
 | 
			
		||||
    selectOption(undefined);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Account Data</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Global"
 | 
			
		||||
          description="Data stored in your global account data."
 | 
			
		||||
          after={
 | 
			
		||||
            <Button
 | 
			
		||||
              onClick={() => setView(!view)}
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              outlined
 | 
			
		||||
              before={
 | 
			
		||||
                <Icon src={view ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">{view ? 'Collapse' : 'Expand'}</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
        {view && (
 | 
			
		||||
          <SettingTile>
 | 
			
		||||
            <Box direction="Column" gap="200">
 | 
			
		||||
              <Text size="L400">Types</Text>
 | 
			
		||||
              <Box gap="200" wrap="Wrap">
 | 
			
		||||
                <Chip
 | 
			
		||||
                  variant="Secondary"
 | 
			
		||||
                  fill="Soft"
 | 
			
		||||
                  radii="Pill"
 | 
			
		||||
                  onClick={handleEdit}
 | 
			
		||||
                  before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="T200" truncate>
 | 
			
		||||
                    Add New
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
                {accountData.map((mEvent) => (
 | 
			
		||||
                  <Chip
 | 
			
		||||
                    key={mEvent.getType()}
 | 
			
		||||
                    variant="Secondary"
 | 
			
		||||
                    fill="Soft"
 | 
			
		||||
                    radii="Pill"
 | 
			
		||||
                    aria-pressed={menuCords && selectedEvent?.getType() === mEvent.getType()}
 | 
			
		||||
                    onClick={handleMenu}
 | 
			
		||||
                    data-event-type={mEvent.getType()}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="T200" truncate>
 | 
			
		||||
                      {mEvent.getType()}
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Chip>
 | 
			
		||||
                ))}
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </SettingTile>
 | 
			
		||||
        )}
 | 
			
		||||
        <PopOut
 | 
			
		||||
          anchor={menuCords}
 | 
			
		||||
          offset={5}
 | 
			
		||||
          position="Bottom"
 | 
			
		||||
          content={
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                onDeactivate: handleMenuClose,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                  evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                  evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Menu>
 | 
			
		||||
                <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                  <MenuItem size="300" variant="Surface" radii="300" onClick={handleInspect}>
 | 
			
		||||
                    <Text size="T300">Inspect</Text>
 | 
			
		||||
                  </MenuItem>
 | 
			
		||||
                  <MenuItem size="300" variant="Surface" radii="300" onClick={handleEdit}>
 | 
			
		||||
                    <Text size="T300">Edit</Text>
 | 
			
		||||
                  </MenuItem>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Menu>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      {selectedEvent && selectedOption === 'inspect' && (
 | 
			
		||||
        <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                onDeactivate: handleClose,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal variant="Surface" size="500">
 | 
			
		||||
                <TextViewer
 | 
			
		||||
                  name={selectedEvent.getType() ?? 'Source Code'}
 | 
			
		||||
                  langName="json"
 | 
			
		||||
                  text={JSON.stringify(selectedEvent.getContent(), null, 2)}
 | 
			
		||||
                  requestClose={handleClose}
 | 
			
		||||
                />
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
      {selectedOption === 'edit' && (
 | 
			
		||||
        <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                onDeactivate: handleClose,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal variant="Surface" size="500">
 | 
			
		||||
                <AccountDataEditor
 | 
			
		||||
                  type={selectedEvent?.getType()}
 | 
			
		||||
                  content={selectedEvent?.getContent()}
 | 
			
		||||
                  requestClose={handleClose}
 | 
			
		||||
                />
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DeveloperToolsProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false}>
 | 
			
		||||
        <Box grow="Yes" gap="200">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Text size="H3" truncate>
 | 
			
		||||
              Developer Tools
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Options</Text>
 | 
			
		||||
                <SequenceCard
 | 
			
		||||
                  className={SequenceCardStyle}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  direction="Column"
 | 
			
		||||
                  gap="400"
 | 
			
		||||
                >
 | 
			
		||||
                  <SettingTile
 | 
			
		||||
                    title="Enable Developer Tools"
 | 
			
		||||
                    after={
 | 
			
		||||
                      <Switch
 | 
			
		||||
                        variant="Primary"
 | 
			
		||||
                        value={developerTools}
 | 
			
		||||
                        onChange={setDeveloperTools}
 | 
			
		||||
                      />
 | 
			
		||||
                    }
 | 
			
		||||
                  />
 | 
			
		||||
                </SequenceCard>
 | 
			
		||||
                {developerTools && (
 | 
			
		||||
                  <SequenceCard
 | 
			
		||||
                    className={SequenceCardStyle}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="400"
 | 
			
		||||
                  >
 | 
			
		||||
                    <SettingTile
 | 
			
		||||
                      title="Access Token"
 | 
			
		||||
                      description="Copy access token to clipboard."
 | 
			
		||||
                      after={
 | 
			
		||||
                        <Button
 | 
			
		||||
                          onClick={() =>
 | 
			
		||||
                            copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
 | 
			
		||||
                          }
 | 
			
		||||
                          variant="Secondary"
 | 
			
		||||
                          fill="Soft"
 | 
			
		||||
                          size="300"
 | 
			
		||||
                          radii="300"
 | 
			
		||||
                          outlined
 | 
			
		||||
                        >
 | 
			
		||||
                          <Text size="B300">Copy</Text>
 | 
			
		||||
                        </Button>
 | 
			
		||||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                  </SequenceCard>
 | 
			
		||||
                )}
 | 
			
		||||
              </Box>
 | 
			
		||||
              {developerTools && <AccountData />}
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/settings/developer-tools/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/settings/developer-tools/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './DevelopTools';
 | 
			
		||||
							
								
								
									
										24
									
								
								src/app/features/settings/developer-tools/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/app/features/settings/developer-tools/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { DefaultReset, config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const EditorHeader = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    paddingLeft: config.space.S400,
 | 
			
		||||
    paddingRight: config.space.S200,
 | 
			
		||||
    borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
    flexShrink: 0,
 | 
			
		||||
    gap: config.space.S200,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const EditorContent = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    padding: config.space.S400,
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const EditorTextArea = style({
 | 
			
		||||
  fontFamily: 'monospace',
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										332
									
								
								src/app/features/settings/devices/DeviceTile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										332
									
								
								src/app/features/settings/devices/DeviceTile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,332 @@
 | 
			
		|||
import React, { FormEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Chip,
 | 
			
		||||
  Input,
 | 
			
		||||
  Button,
 | 
			
		||||
  color,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  toRem,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
 | 
			
		||||
import { BreakWord } from '../../../styles/Text.css';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { LogoutDialog } from '../../../components/LogoutDialog';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
export function DeviceTilePlaceholder() {
 | 
			
		||||
  return (
 | 
			
		||||
    <SequenceCard
 | 
			
		||||
      className={SequenceCardStyle}
 | 
			
		||||
      style={{ height: toRem(66) }}
 | 
			
		||||
      variant="SurfaceVariant"
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      gap="400"
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DeviceActiveTime({ ts }: { ts: number }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Text className={BreakWord} size="T200">
 | 
			
		||||
      <Text size="Inherit" as="span" priority="300">
 | 
			
		||||
        {'Last activity: '}
 | 
			
		||||
      </Text>
 | 
			
		||||
      <>
 | 
			
		||||
        {today(ts) && 'Today'}
 | 
			
		||||
        {yesterday(ts) && 'Yesterday'}
 | 
			
		||||
        {!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
 | 
			
		||||
      </>
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DeviceDetails({ device }: { device: IMyDevice }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {typeof device.device_id === 'string' && (
 | 
			
		||||
        <Text className={BreakWord} size="T200" priority="300">
 | 
			
		||||
          Device ID: <i>{device.device_id}</i>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
      {typeof device.last_seen_ip === 'string' && (
 | 
			
		||||
        <Text className={BreakWord} size="T200" priority="300">
 | 
			
		||||
          IP Address: <i>{device.last_seen_ip}</i>
 | 
			
		||||
        </Text>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DeviceKeyDetailsProps = {
 | 
			
		||||
  crypto: CryptoApi;
 | 
			
		||||
};
 | 
			
		||||
export function DeviceKeyDetails({ crypto }: DeviceKeyDetailsProps) {
 | 
			
		||||
  const [keysState, loadKeys] = useAsyncCallback(
 | 
			
		||||
    useCallback(() => {
 | 
			
		||||
      const keys = crypto.getOwnDeviceKeys();
 | 
			
		||||
      return keys;
 | 
			
		||||
    }, [crypto])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    loadKeys();
 | 
			
		||||
  }, [loadKeys]);
 | 
			
		||||
 | 
			
		||||
  if (keysState.status === AsyncStatus.Error) return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Text className={BreakWord} size="T200" priority="300">
 | 
			
		||||
      Device Key:{' '}
 | 
			
		||||
      <i>{keysState.status === AsyncStatus.Success ? keysState.data.ed25519 : 'loading...'}</i>
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DeviceRenameProps = {
 | 
			
		||||
  device: IMyDevice;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  onRename: () => void;
 | 
			
		||||
  refreshDeviceList: () => Promise<void>;
 | 
			
		||||
};
 | 
			
		||||
function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceRenameProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [renameState, rename] = useAsyncCallback<void, MatrixError, [string]>(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (name: string) => {
 | 
			
		||||
        await mx.setDeviceDetails(device.device_id, { display_name: name });
 | 
			
		||||
        await refreshDeviceList();
 | 
			
		||||
      },
 | 
			
		||||
      [mx, device.device_id, refreshDeviceList]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const renaming = renameState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (renameState.status === AsyncStatus.Success) {
 | 
			
		||||
      onRename();
 | 
			
		||||
    }
 | 
			
		||||
  }, [renameState, onRename]);
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (renaming) return;
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const nameInput = target?.nameInput as HTMLInputElement | undefined;
 | 
			
		||||
    if (!nameInput) return;
 | 
			
		||||
    const deviceName = nameInput.value.trim();
 | 
			
		||||
    if (!deviceName || deviceName === device.display_name) return;
 | 
			
		||||
 | 
			
		||||
    rename(deviceName);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Device Name</Text>
 | 
			
		||||
      <Box gap="200">
 | 
			
		||||
        <Box grow="Yes" direction="Column">
 | 
			
		||||
          <Input
 | 
			
		||||
            name="nameInput"
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            defaultValue={device.display_name}
 | 
			
		||||
            autoFocus
 | 
			
		||||
            required
 | 
			
		||||
            readOnly={renaming}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No" gap="200">
 | 
			
		||||
          <Button
 | 
			
		||||
            type="submit"
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Success"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            fill="Solid"
 | 
			
		||||
            disabled={renaming}
 | 
			
		||||
            before={renaming && <Spinner size="100" variant="Success" fill="Solid" />}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Save</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button
 | 
			
		||||
            type="button"
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            onClick={onCancel}
 | 
			
		||||
            disabled={renaming}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Cancel</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      {renameState.status === AsyncStatus.Error ? (
 | 
			
		||||
        <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
          {renameState.error.message}
 | 
			
		||||
        </Text>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Text size="T200">Device names are visible to public.</Text>
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function DeviceLogoutBtn() {
 | 
			
		||||
  const [prompt, setPrompt] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleClose = () => setPrompt(false);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Chip variant="Secondary" fill="Soft" radii="Pill" onClick={() => setPrompt(true)}>
 | 
			
		||||
        <Text size="B300">Logout</Text>
 | 
			
		||||
      </Chip>
 | 
			
		||||
      {prompt && (
 | 
			
		||||
        <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                onDeactivate: handleClose,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <LogoutDialog handleClose={handleClose} />
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DeviceDeleteBtnProps = {
 | 
			
		||||
  deviceId: string;
 | 
			
		||||
  deleted: boolean;
 | 
			
		||||
  onDeleteToggle: (deviceId: string) => void;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function DeviceDeleteBtn({
 | 
			
		||||
  deviceId,
 | 
			
		||||
  deleted,
 | 
			
		||||
  onDeleteToggle,
 | 
			
		||||
  disabled,
 | 
			
		||||
}: DeviceDeleteBtnProps) {
 | 
			
		||||
  return deleted ? (
 | 
			
		||||
    <Chip
 | 
			
		||||
      variant="Critical"
 | 
			
		||||
      fill="None"
 | 
			
		||||
      radii="Pill"
 | 
			
		||||
      onClick={() => onDeleteToggle(deviceId)}
 | 
			
		||||
      disabled={disabled}
 | 
			
		||||
    >
 | 
			
		||||
      <Text size="B300">Undo</Text>
 | 
			
		||||
    </Chip>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <Chip
 | 
			
		||||
      variant="Secondary"
 | 
			
		||||
      fill="None"
 | 
			
		||||
      radii="Pill"
 | 
			
		||||
      onClick={() => onDeleteToggle(deviceId)}
 | 
			
		||||
      disabled={disabled}
 | 
			
		||||
    >
 | 
			
		||||
      <Icon size="50" src={Icons.Delete} />
 | 
			
		||||
    </Chip>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DeviceTileProps = {
 | 
			
		||||
  device: IMyDevice;
 | 
			
		||||
  deleted?: boolean;
 | 
			
		||||
  refreshDeviceList: () => Promise<void>;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  options?: ReactNode;
 | 
			
		||||
  children?: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function DeviceTile({
 | 
			
		||||
  device,
 | 
			
		||||
  deleted,
 | 
			
		||||
  refreshDeviceList,
 | 
			
		||||
  disabled,
 | 
			
		||||
  options,
 | 
			
		||||
  children,
 | 
			
		||||
}: DeviceTileProps) {
 | 
			
		||||
  const activeTs = device.last_seen_ts;
 | 
			
		||||
  const [details, setDetails] = useState(false);
 | 
			
		||||
  const [edit, setEdit] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleRename = useCallback(() => {
 | 
			
		||||
    setEdit(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        before={
 | 
			
		||||
          <IconButton
 | 
			
		||||
            variant={deleted ? 'Critical' : 'Secondary'}
 | 
			
		||||
            outlined={deleted}
 | 
			
		||||
            radii="300"
 | 
			
		||||
            onClick={() => setDetails(!details)}
 | 
			
		||||
          >
 | 
			
		||||
            <Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        }
 | 
			
		||||
        after={
 | 
			
		||||
          !edit && (
 | 
			
		||||
            <Box shrink="No" alignItems="Center" gap="200">
 | 
			
		||||
              {options}
 | 
			
		||||
              {!deleted && (
 | 
			
		||||
                <Chip
 | 
			
		||||
                  variant="Secondary"
 | 
			
		||||
                  radii="Pill"
 | 
			
		||||
                  onClick={() => setEdit(true)}
 | 
			
		||||
                  disabled={disabled}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">Edit</Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              )}
 | 
			
		||||
            </Box>
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="T300">{device.display_name ?? device.device_id}</Text>
 | 
			
		||||
        <Box direction="Column">
 | 
			
		||||
          {typeof activeTs === 'number' && <DeviceActiveTime ts={activeTs} />}
 | 
			
		||||
          {details && (
 | 
			
		||||
            <>
 | 
			
		||||
              <DeviceDetails device={device} />
 | 
			
		||||
              {children}
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </SettingTile>
 | 
			
		||||
      {edit && (
 | 
			
		||||
        <DeviceRename
 | 
			
		||||
          device={device}
 | 
			
		||||
          onCancel={() => setEdit(false)}
 | 
			
		||||
          onRename={handleRename}
 | 
			
		||||
          refreshDeviceList={refreshDeviceList}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										165
									
								
								src/app/features/settings/devices/Devices.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								src/app/features/settings/devices/Devices.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,165 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
 | 
			
		||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '../../../hooks/useDeviceList';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { LocalBackup } from './LocalBackup';
 | 
			
		||||
import { DeviceLogoutBtn, DeviceKeyDetails, DeviceTile, DeviceTilePlaceholder } from './DeviceTile';
 | 
			
		||||
import { OtherDevices } from './OtherDevices';
 | 
			
		||||
import {
 | 
			
		||||
  DeviceVerificationOptions,
 | 
			
		||||
  EnableVerification,
 | 
			
		||||
  VerificationStatusBadge,
 | 
			
		||||
  VerifyCurrentDeviceTile,
 | 
			
		||||
} from './Verification';
 | 
			
		||||
import {
 | 
			
		||||
  useDeviceVerificationStatus,
 | 
			
		||||
  useUnverifiedDeviceCount,
 | 
			
		||||
  VerificationStatus,
 | 
			
		||||
} from '../../../hooks/useDeviceVerificationStatus';
 | 
			
		||||
import {
 | 
			
		||||
  useSecretStorageDefaultKeyId,
 | 
			
		||||
  useSecretStorageKeyContent,
 | 
			
		||||
} from '../../../hooks/useSecretStorage';
 | 
			
		||||
import { useCrossSigningActive } from '../../../hooks/useCrossSigning';
 | 
			
		||||
import { BackupRestoreTile } from '../../../components/BackupRestore';
 | 
			
		||||
 | 
			
		||||
function DevicesPlaceholder() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <DeviceTilePlaceholder />
 | 
			
		||||
      <DeviceTilePlaceholder />
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DevicesProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function Devices({ requestClose }: DevicesProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const crypto = mx.getCrypto();
 | 
			
		||||
  const crossSigningActive = useCrossSigningActive();
 | 
			
		||||
  const [devices, refreshDeviceList] = useDeviceList();
 | 
			
		||||
 | 
			
		||||
  const [currentDevice, otherDevices] = useSplitCurrentDevice(devices);
 | 
			
		||||
  const verificationStatus = useDeviceVerificationStatus(
 | 
			
		||||
    crypto,
 | 
			
		||||
    mx.getSafeUserId(),
 | 
			
		||||
    currentDevice?.device_id
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const otherDevicesId = useDeviceIds(otherDevices);
 | 
			
		||||
  const unverifiedDeviceCount = useUnverifiedDeviceCount(
 | 
			
		||||
    crypto,
 | 
			
		||||
    mx.getSafeUserId(),
 | 
			
		||||
    otherDevicesId
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const defaultSecretStorageKeyId = useSecretStorageDefaultKeyId();
 | 
			
		||||
  const defaultSecretStorageKeyContent = useSecretStorageKeyContent(
 | 
			
		||||
    defaultSecretStorageKeyId ?? ''
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false}>
 | 
			
		||||
        <Box grow="Yes" gap="200">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Text size="H3" truncate>
 | 
			
		||||
              Devices
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Security</Text>
 | 
			
		||||
                <SequenceCard
 | 
			
		||||
                  className={SequenceCardStyle}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  direction="Column"
 | 
			
		||||
                  gap="400"
 | 
			
		||||
                >
 | 
			
		||||
                  <SettingTile
 | 
			
		||||
                    title="Device Verification"
 | 
			
		||||
                    description="To verify device identity and grant access to encrypted messages."
 | 
			
		||||
                    after={
 | 
			
		||||
                      <>
 | 
			
		||||
                        <EnableVerification visible={!crossSigningActive} />
 | 
			
		||||
                        {crossSigningActive && (
 | 
			
		||||
                          <Box gap="200" alignItems="Center">
 | 
			
		||||
                            <VerificationStatusBadge
 | 
			
		||||
                              verificationStatus={verificationStatus}
 | 
			
		||||
                              otherUnverifiedCount={unverifiedDeviceCount}
 | 
			
		||||
                            />
 | 
			
		||||
                            <DeviceVerificationOptions />
 | 
			
		||||
                          </Box>
 | 
			
		||||
                        )}
 | 
			
		||||
                      </>
 | 
			
		||||
                    }
 | 
			
		||||
                  />
 | 
			
		||||
                </SequenceCard>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Current</Text>
 | 
			
		||||
                {currentDevice ? (
 | 
			
		||||
                  <SequenceCard
 | 
			
		||||
                    className={SequenceCardStyle}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="400"
 | 
			
		||||
                  >
 | 
			
		||||
                    <DeviceTile
 | 
			
		||||
                      device={currentDevice}
 | 
			
		||||
                      refreshDeviceList={refreshDeviceList}
 | 
			
		||||
                      options={<DeviceLogoutBtn />}
 | 
			
		||||
                    >
 | 
			
		||||
                      {crypto && <DeviceKeyDetails crypto={crypto} />}
 | 
			
		||||
                    </DeviceTile>
 | 
			
		||||
                    {crossSigningActive &&
 | 
			
		||||
                      verificationStatus === VerificationStatus.Unverified &&
 | 
			
		||||
                      defaultSecretStorageKeyId &&
 | 
			
		||||
                      defaultSecretStorageKeyContent && (
 | 
			
		||||
                        <VerifyCurrentDeviceTile
 | 
			
		||||
                          secretStorageKeyId={defaultSecretStorageKeyId}
 | 
			
		||||
                          secretStorageKeyContent={defaultSecretStorageKeyContent}
 | 
			
		||||
                        />
 | 
			
		||||
                      )}
 | 
			
		||||
                    {crypto && verificationStatus === VerificationStatus.Verified && (
 | 
			
		||||
                      <BackupRestoreTile crypto={crypto} />
 | 
			
		||||
                    )}
 | 
			
		||||
                  </SequenceCard>
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <DeviceTilePlaceholder />
 | 
			
		||||
                )}
 | 
			
		||||
              </Box>
 | 
			
		||||
              {devices === undefined && <DevicesPlaceholder />}
 | 
			
		||||
              {otherDevices && (
 | 
			
		||||
                <OtherDevices
 | 
			
		||||
                  devices={otherDevices}
 | 
			
		||||
                  refreshDeviceList={refreshDeviceList}
 | 
			
		||||
                  showVerification={
 | 
			
		||||
                    crossSigningActive && verificationStatus === VerificationStatus.Verified
 | 
			
		||||
                  }
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
              <LocalBackup />
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										325
									
								
								src/app/features/settings/devices/LocalBackup.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								src/app/features/settings/devices/LocalBackup.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,325 @@
 | 
			
		|||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
 | 
			
		||||
import FileSaver from 'file-saver';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { PasswordInput } from '../../../components/password-input';
 | 
			
		||||
import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys';
 | 
			
		||||
import { useAlive } from '../../../hooks/useAlive';
 | 
			
		||||
import { useFilePicker } from '../../../hooks/useFilePicker';
 | 
			
		||||
 | 
			
		||||
function ExportKeys() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (password) => {
 | 
			
		||||
        const crypto = mx.getCrypto();
 | 
			
		||||
        if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
 | 
			
		||||
        const keysJSON = await crypto.exportRoomKeysAsJson();
 | 
			
		||||
 | 
			
		||||
        const encKeys = await encryptMegolmKeyFile(keysJSON, password);
 | 
			
		||||
 | 
			
		||||
        const blob = new Blob([encKeys], {
 | 
			
		||||
          type: 'text/plain;charset=us-ascii',
 | 
			
		||||
        });
 | 
			
		||||
        FileSaver.saveAs(blob, 'cinny-keys.txt');
 | 
			
		||||
      },
 | 
			
		||||
      [mx]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const exporting = exportState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (exporting) return;
 | 
			
		||||
 | 
			
		||||
    const { passwordInput, confirmPasswordInput } = evt.target as HTMLFormElement & {
 | 
			
		||||
      passwordInput: HTMLInputElement;
 | 
			
		||||
      confirmPasswordInput: HTMLInputElement;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const password = passwordInput.value;
 | 
			
		||||
    const confirmPassword = confirmPasswordInput.value;
 | 
			
		||||
 | 
			
		||||
    if (password !== confirmPassword) return;
 | 
			
		||||
 | 
			
		||||
    exportKeys(password).then(() => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        passwordInput.value = '';
 | 
			
		||||
        confirmPasswordInput.value = '';
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile>
 | 
			
		||||
      <Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
 | 
			
		||||
        <Box gap="200" alignItems="End">
 | 
			
		||||
          <ConfirmPasswordMatch initialValue>
 | 
			
		||||
            {(match, doMatch, passRef, confPassRef) => (
 | 
			
		||||
              <>
 | 
			
		||||
                <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
                  <Text size="L400">New Password</Text>
 | 
			
		||||
                  <PasswordInput
 | 
			
		||||
                    ref={passRef}
 | 
			
		||||
                    name="passwordInput"
 | 
			
		||||
                    size="400"
 | 
			
		||||
                    variant="Secondary"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    required
 | 
			
		||||
                    onChange={doMatch}
 | 
			
		||||
                    readOnly={exporting}
 | 
			
		||||
                    autoFocus
 | 
			
		||||
                  />
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
                  <Text size="L400">Confirm Password</Text>
 | 
			
		||||
                  <PasswordInput
 | 
			
		||||
                    ref={confPassRef}
 | 
			
		||||
                    style={{ color: match ? undefined : color.Critical.Main }}
 | 
			
		||||
                    name="confirmPasswordInput"
 | 
			
		||||
                    size="400"
 | 
			
		||||
                    variant="Secondary"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    required
 | 
			
		||||
                    onChange={doMatch}
 | 
			
		||||
                    readOnly={exporting}
 | 
			
		||||
                  />
 | 
			
		||||
                </Box>
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
          </ConfirmPasswordMatch>
 | 
			
		||||
          <Button
 | 
			
		||||
            type="submit"
 | 
			
		||||
            size="400"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={exporting}
 | 
			
		||||
            before={exporting ? <Spinner size="200" variant="Secondary" fill="Soft" /> : undefined}
 | 
			
		||||
          >
 | 
			
		||||
            <Text as="span" size="B400">
 | 
			
		||||
              Export
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
        {exportState.status === AsyncStatus.Error && (
 | 
			
		||||
          <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
            <b>{exportState.error.message}</b>
 | 
			
		||||
          </Text>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ExportKeysTile() {
 | 
			
		||||
  const [expand, setExpand] = useState(false);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Export Messages Data"
 | 
			
		||||
        description="Save password protected copy of encryption data on your device to decrypt messages later."
 | 
			
		||||
        after={
 | 
			
		||||
          <Box>
 | 
			
		||||
            <Button
 | 
			
		||||
              type="button"
 | 
			
		||||
              onClick={() => setExpand(!expand)}
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              outlined
 | 
			
		||||
              radii="300"
 | 
			
		||||
              before={
 | 
			
		||||
                <Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
              <Text as="span" size="B300" truncate>
 | 
			
		||||
                {expand ? 'Collapse' : 'Expand'}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          </Box>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {expand && <ExportKeys />}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ImportKeysProps = {
 | 
			
		||||
  file: File;
 | 
			
		||||
  onDone?: () => void;
 | 
			
		||||
};
 | 
			
		||||
function ImportKeys({ file, onDone }: ImportKeysProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const [decryptState, decryptFile] = useAsyncCallback<void, Error, [string]>(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (password) => {
 | 
			
		||||
        const crypto = mx.getCrypto();
 | 
			
		||||
        if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
 | 
			
		||||
 | 
			
		||||
        const arrayBuffer = await file.arrayBuffer();
 | 
			
		||||
        const keys = await decryptMegolmKeyFile(arrayBuffer, password);
 | 
			
		||||
 | 
			
		||||
        await crypto.importRoomKeysAsJson(keys);
 | 
			
		||||
      },
 | 
			
		||||
      [file, mx]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const decrypting = decryptState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (decryptState.status === AsyncStatus.Success) {
 | 
			
		||||
      onDone?.();
 | 
			
		||||
    }
 | 
			
		||||
  }, [onDone, decryptState]);
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (decrypting) return;
 | 
			
		||||
 | 
			
		||||
    const { passwordInput } = evt.target as HTMLFormElement & {
 | 
			
		||||
      passwordInput: HTMLInputElement;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const password = passwordInput.value;
 | 
			
		||||
 | 
			
		||||
    if (!password) return;
 | 
			
		||||
    decryptFile(password).then(() => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        passwordInput.value = '';
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile>
 | 
			
		||||
      <Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
 | 
			
		||||
        <Box gap="200" alignItems="End">
 | 
			
		||||
          <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
            <Text size="L400">Password</Text>
 | 
			
		||||
            <PasswordInput
 | 
			
		||||
              name="passwordInput"
 | 
			
		||||
              size="400"
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              required
 | 
			
		||||
              autoFocus
 | 
			
		||||
              readOnly={decrypting}
 | 
			
		||||
            />
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Button
 | 
			
		||||
            type="submit"
 | 
			
		||||
            size="400"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={decrypting}
 | 
			
		||||
            before={decrypting ? <Spinner size="200" variant="Secondary" fill="Soft" /> : undefined}
 | 
			
		||||
          >
 | 
			
		||||
            <Text as="span" size="B400">
 | 
			
		||||
              Decrypt
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
        {decryptState.status === AsyncStatus.Error && (
 | 
			
		||||
          <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
            <b>{decryptState.error.message}</b>
 | 
			
		||||
          </Text>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ImportKeysTile() {
 | 
			
		||||
  const [file, setFile] = useState<File>();
 | 
			
		||||
  const pickFile = useFilePicker(setFile);
 | 
			
		||||
 | 
			
		||||
  const handleDone = useCallback(() => {
 | 
			
		||||
    setFile(undefined);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Import Messages Data"
 | 
			
		||||
        description="Load password protected copy of encryption data from device to decrypt your messages."
 | 
			
		||||
        after={
 | 
			
		||||
          <Box>
 | 
			
		||||
            {file ? (
 | 
			
		||||
              <Button
 | 
			
		||||
                style={{ maxWidth: toRem(200) }}
 | 
			
		||||
                type="button"
 | 
			
		||||
                onClick={() => setFile(undefined)}
 | 
			
		||||
                size="300"
 | 
			
		||||
                variant="Warning"
 | 
			
		||||
                fill="Solid"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                before={<Icon size="100" src={Icons.File} filled />}
 | 
			
		||||
                after={<Icon size="100" src={Icons.Cross} />}
 | 
			
		||||
              >
 | 
			
		||||
                <Text as="span" size="B300" truncate>
 | 
			
		||||
                  {file.name}
 | 
			
		||||
                </Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <Button
 | 
			
		||||
                type="button"
 | 
			
		||||
                onClick={() => pickFile('text/plain')}
 | 
			
		||||
                size="300"
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
                fill="Soft"
 | 
			
		||||
                outlined
 | 
			
		||||
                radii="300"
 | 
			
		||||
                before={<Icon size="100" src={Icons.ArrowRight} />}
 | 
			
		||||
              >
 | 
			
		||||
                <Text as="span" size="B300">
 | 
			
		||||
                  Import
 | 
			
		||||
                </Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {file && <ImportKeys file={file} onDone={handleDone} />}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LocalBackup() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Local Backup</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <ExportKeysTile />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <ImportKeysTile />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										187
									
								
								src/app/features/settings/devices/OtherDevices.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								src/app/features/settings/devices/OtherDevices.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,187 @@
 | 
			
		|||
import React, { useCallback, useState } from 'react';
 | 
			
		||||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
 | 
			
		||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA';
 | 
			
		||||
import { DeviceDeleteBtn, DeviceTile } from './DeviceTile';
 | 
			
		||||
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
 | 
			
		||||
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
 | 
			
		||||
import { VerifyOtherDeviceTile } from './Verification';
 | 
			
		||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
 | 
			
		||||
 | 
			
		||||
type OtherDevicesProps = {
 | 
			
		||||
  devices: IMyDevice[];
 | 
			
		||||
  refreshDeviceList: () => Promise<void>;
 | 
			
		||||
  showVerification?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const crypto = mx.getCrypto();
 | 
			
		||||
  const [deleted, setDeleted] = useState<Set<string>>(new Set());
 | 
			
		||||
 | 
			
		||||
  const handleToggleDelete = useCallback((deviceId: string) => {
 | 
			
		||||
    setDeleted((deviceIds) => {
 | 
			
		||||
      const newIds = new Set(deviceIds);
 | 
			
		||||
      if (newIds.has(deviceId)) {
 | 
			
		||||
        newIds.delete(deviceId);
 | 
			
		||||
      } else {
 | 
			
		||||
        newIds.add(deviceId);
 | 
			
		||||
      }
 | 
			
		||||
      return newIds;
 | 
			
		||||
    });
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const [deleteState, setDeleteState] = useState<AsyncState<void, MatrixError>>({
 | 
			
		||||
    status: AsyncStatus.Idle,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const deleteDevices = useAsync(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (authDict?: AuthDict) => {
 | 
			
		||||
        await mx.deleteMultipleDevices(Array.from(deleted), authDict);
 | 
			
		||||
      },
 | 
			
		||||
      [mx, deleted]
 | 
			
		||||
    ),
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (state: typeof deleteState) => {
 | 
			
		||||
        if (state.status === AsyncStatus.Success) {
 | 
			
		||||
          setDeleted(new Set());
 | 
			
		||||
          refreshDeviceList();
 | 
			
		||||
        }
 | 
			
		||||
        setDeleteState(state);
 | 
			
		||||
      },
 | 
			
		||||
      [refreshDeviceList]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
  const [authData, deleteError] = useUIAMatrixError(
 | 
			
		||||
    deleteState.status === AsyncStatus.Error ? deleteState.error : undefined
 | 
			
		||||
  );
 | 
			
		||||
  const deleting = deleteState.status === AsyncStatus.Loading || authData !== undefined;
 | 
			
		||||
 | 
			
		||||
  const handleCancelDelete = () => setDeleted(new Set());
 | 
			
		||||
  const handleCancelAuth = useCallback(() => {
 | 
			
		||||
    setDeleteState({ status: AsyncStatus.Idle });
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return devices.length > 0 ? (
 | 
			
		||||
    <>
 | 
			
		||||
      <Box direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Others</Text>
 | 
			
		||||
        {devices
 | 
			
		||||
          .sort((d1, d2) => {
 | 
			
		||||
            if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
 | 
			
		||||
            return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
 | 
			
		||||
          })
 | 
			
		||||
          .map((device) => (
 | 
			
		||||
            <SequenceCard
 | 
			
		||||
              key={device.device_id}
 | 
			
		||||
              className={SequenceCardStyle}
 | 
			
		||||
              variant={deleted.has(device.device_id) ? 'Critical' : 'SurfaceVariant'}
 | 
			
		||||
              direction="Column"
 | 
			
		||||
              gap="400"
 | 
			
		||||
            >
 | 
			
		||||
              <DeviceTile
 | 
			
		||||
                device={device}
 | 
			
		||||
                deleted={deleted.has(device.device_id)}
 | 
			
		||||
                refreshDeviceList={refreshDeviceList}
 | 
			
		||||
                disabled={deleting}
 | 
			
		||||
                options={
 | 
			
		||||
                  <DeviceDeleteBtn
 | 
			
		||||
                    deviceId={device.device_id}
 | 
			
		||||
                    deleted={deleted.has(device.device_id)}
 | 
			
		||||
                    onDeleteToggle={handleToggleDelete}
 | 
			
		||||
                    disabled={deleting}
 | 
			
		||||
                  />
 | 
			
		||||
                }
 | 
			
		||||
              />
 | 
			
		||||
              {showVerification && crypto && (
 | 
			
		||||
                <DeviceVerificationStatus
 | 
			
		||||
                  crypto={crypto}
 | 
			
		||||
                  userId={mx.getSafeUserId()}
 | 
			
		||||
                  deviceId={device.device_id}
 | 
			
		||||
                >
 | 
			
		||||
                  {(status) =>
 | 
			
		||||
                    status === VerificationStatus.Unverified && (
 | 
			
		||||
                      <VerifyOtherDeviceTile crypto={crypto} deviceId={device.device_id} />
 | 
			
		||||
                    )
 | 
			
		||||
                  }
 | 
			
		||||
                </DeviceVerificationStatus>
 | 
			
		||||
              )}
 | 
			
		||||
            </SequenceCard>
 | 
			
		||||
          ))}
 | 
			
		||||
      </Box>
 | 
			
		||||
      {deleted.size > 0 && (
 | 
			
		||||
        <Menu
 | 
			
		||||
          style={{
 | 
			
		||||
            position: 'sticky',
 | 
			
		||||
            padding: config.space.S200,
 | 
			
		||||
            paddingLeft: config.space.S400,
 | 
			
		||||
            bottom: config.space.S400,
 | 
			
		||||
            left: config.space.S400,
 | 
			
		||||
            right: 0,
 | 
			
		||||
            zIndex: 1,
 | 
			
		||||
          }}
 | 
			
		||||
          variant="Critical"
 | 
			
		||||
        >
 | 
			
		||||
          <Box alignItems="Center" gap="400">
 | 
			
		||||
            <Box grow="Yes" direction="Column">
 | 
			
		||||
              {deleteError ? (
 | 
			
		||||
                <Text size="T200">
 | 
			
		||||
                  <b>Failed to logout devices! Please try again. {deleteError.message}</b>
 | 
			
		||||
                </Text>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <Text size="T200">
 | 
			
		||||
                  <b>Logout from selected devices. ({deleted.size} selected)</b>
 | 
			
		||||
                </Text>
 | 
			
		||||
              )}
 | 
			
		||||
              {authData && (
 | 
			
		||||
                <ActionUIAFlowsLoader
 | 
			
		||||
                  authData={authData}
 | 
			
		||||
                  unsupported={() => (
 | 
			
		||||
                    <Text size="T200">
 | 
			
		||||
                      Authentication steps to perform this action are not supported by client.
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  )}
 | 
			
		||||
                >
 | 
			
		||||
                  {(ongoingFlow) => (
 | 
			
		||||
                    <ActionUIA
 | 
			
		||||
                      authData={authData}
 | 
			
		||||
                      ongoingFlow={ongoingFlow}
 | 
			
		||||
                      action={deleteDevices}
 | 
			
		||||
                      onCancel={handleCancelAuth}
 | 
			
		||||
                    />
 | 
			
		||||
                  )}
 | 
			
		||||
                </ActionUIAFlowsLoader>
 | 
			
		||||
              )}
 | 
			
		||||
            </Box>
 | 
			
		||||
            <Box shrink="No" gap="200">
 | 
			
		||||
              <Button
 | 
			
		||||
                size="300"
 | 
			
		||||
                variant="Critical"
 | 
			
		||||
                fill="None"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                disabled={deleting}
 | 
			
		||||
                onClick={handleCancelDelete}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">Cancel</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
              <Button
 | 
			
		||||
                size="300"
 | 
			
		||||
                variant="Critical"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                disabled={deleting}
 | 
			
		||||
                before={deleting && <Spinner variant="Critical" fill="Solid" size="100" />}
 | 
			
		||||
                onClick={() => deleteDevices()}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">Logout</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Menu>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  ) : null;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										335
									
								
								src/app/features/settings/devices/Verification.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										335
									
								
								src/app/features/settings/devices/Verification.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,335 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Badge,
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  Menu,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { CryptoApi, VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
 | 
			
		||||
import { InfoCard } from '../../../components/info-card';
 | 
			
		||||
import { ManualVerificationTile } from '../../../components/ManualVerification';
 | 
			
		||||
import { SecretStorageKeyContent } from '../../../../types/matrix/accountData';
 | 
			
		||||
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { DeviceVerification } from '../../../components/DeviceVerification';
 | 
			
		||||
import {
 | 
			
		||||
  DeviceVerificationReset,
 | 
			
		||||
  DeviceVerificationSetup,
 | 
			
		||||
} from '../../../components/DeviceVerificationSetup';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
type VerificationStatusBadgeProps = {
 | 
			
		||||
  verificationStatus: VerificationStatus;
 | 
			
		||||
  otherUnverifiedCount?: number;
 | 
			
		||||
};
 | 
			
		||||
export function VerificationStatusBadge({
 | 
			
		||||
  verificationStatus,
 | 
			
		||||
  otherUnverifiedCount,
 | 
			
		||||
}: VerificationStatusBadgeProps) {
 | 
			
		||||
  if (
 | 
			
		||||
    verificationStatus === VerificationStatus.Unknown ||
 | 
			
		||||
    typeof otherUnverifiedCount !== 'number'
 | 
			
		||||
  ) {
 | 
			
		||||
    return <Spinner size="400" variant="Secondary" />;
 | 
			
		||||
  }
 | 
			
		||||
  if (verificationStatus === VerificationStatus.Unverified) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Badge variant="Critical" fill="Solid" size="500">
 | 
			
		||||
        <Text size="L400">Unverified</Text>
 | 
			
		||||
      </Badge>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (otherUnverifiedCount > 0) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Badge variant="Warning" fill="Solid" size="500">
 | 
			
		||||
        <Text size="L400">{otherUnverifiedCount} Unverified</Text>
 | 
			
		||||
      </Badge>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Badge variant="Success" fill="Solid" size="500">
 | 
			
		||||
      <Text size="L400">Verified</Text>
 | 
			
		||||
    </Badge>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function LearnStartVerificationFromOtherDevice() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column">
 | 
			
		||||
      <Text size="T200">Steps to verify from other device.</Text>
 | 
			
		||||
      <Text as="div" size="T200">
 | 
			
		||||
        <ul style={{ margin: `${config.space.S100} 0` }}>
 | 
			
		||||
          <li>Open your other verified device.</li>
 | 
			
		||||
          <li>
 | 
			
		||||
            Open <i>Settings</i>.
 | 
			
		||||
          </li>
 | 
			
		||||
          <li>
 | 
			
		||||
            Find this device in <i>Devices/Sessions</i> section.
 | 
			
		||||
          </li>
 | 
			
		||||
          <li>Initiate verification.</li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Text size="T200">
 | 
			
		||||
        If you do not have any verified device press the <i>"Verify Manually"</i> button.
 | 
			
		||||
      </Text>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type VerifyCurrentDeviceTileProps = {
 | 
			
		||||
  secretStorageKeyId: string;
 | 
			
		||||
  secretStorageKeyContent: SecretStorageKeyContent;
 | 
			
		||||
};
 | 
			
		||||
export function VerifyCurrentDeviceTile({
 | 
			
		||||
  secretStorageKeyId,
 | 
			
		||||
  secretStorageKeyContent,
 | 
			
		||||
}: VerifyCurrentDeviceTileProps) {
 | 
			
		||||
  const [learnMore, setLearnMore] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const [manualVerification, setManualVerification] = useState(false);
 | 
			
		||||
  const handleCancelVerification = () => setManualVerification(false);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <InfoCard
 | 
			
		||||
        variant="Critical"
 | 
			
		||||
        title="Unverified"
 | 
			
		||||
        description={
 | 
			
		||||
          <>
 | 
			
		||||
            Start verification from other device or verify manually.{' '}
 | 
			
		||||
            <Text as="a" size="T200" onClick={() => setLearnMore(!learnMore)}>
 | 
			
		||||
              <b>{learnMore ? 'View Less' : 'Learn More'}</b>
 | 
			
		||||
            </Text>
 | 
			
		||||
          </>
 | 
			
		||||
        }
 | 
			
		||||
        after={
 | 
			
		||||
          !manualVerification && (
 | 
			
		||||
            <Button
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              outlined
 | 
			
		||||
              onClick={() => setManualVerification(true)}
 | 
			
		||||
            >
 | 
			
		||||
              <Text as="span" size="B300">
 | 
			
		||||
                Verify Manually
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        {learnMore && <LearnStartVerificationFromOtherDevice />}
 | 
			
		||||
      </InfoCard>
 | 
			
		||||
      {manualVerification && (
 | 
			
		||||
        <ManualVerificationTile
 | 
			
		||||
          secretStorageKeyId={secretStorageKeyId}
 | 
			
		||||
          secretStorageKeyContent={secretStorageKeyContent}
 | 
			
		||||
          options={
 | 
			
		||||
            <Chip
 | 
			
		||||
              type="button"
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              radii="Pill"
 | 
			
		||||
              onClick={handleCancelVerification}
 | 
			
		||||
            >
 | 
			
		||||
              <Icon size="100" src={Icons.Cross} />
 | 
			
		||||
            </Chip>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type VerifyOtherDeviceTileProps = {
 | 
			
		||||
  crypto: CryptoApi;
 | 
			
		||||
  deviceId: string;
 | 
			
		||||
};
 | 
			
		||||
export function VerifyOtherDeviceTile({ crypto, deviceId }: VerifyOtherDeviceTileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [requestState, setRequestState] = useState<AsyncState<VerificationRequest, Error>>({
 | 
			
		||||
    status: AsyncStatus.Idle,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const requestVerification = useAsync<VerificationRequest, Error, []>(
 | 
			
		||||
    useCallback(() => {
 | 
			
		||||
      const requestPromise = crypto.requestDeviceVerification(mx.getSafeUserId(), deviceId);
 | 
			
		||||
      return requestPromise;
 | 
			
		||||
    }, [mx, crypto, deviceId]),
 | 
			
		||||
    setRequestState
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleExit = useCallback(() => {
 | 
			
		||||
    setRequestState({
 | 
			
		||||
      status: AsyncStatus.Idle,
 | 
			
		||||
    });
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const requesting = requestState.status === AsyncStatus.Loading;
 | 
			
		||||
  return (
 | 
			
		||||
    <InfoCard
 | 
			
		||||
      variant="Warning"
 | 
			
		||||
      title="Unverified"
 | 
			
		||||
      description="Verify device identity and grant access to encrypted messages."
 | 
			
		||||
      after={
 | 
			
		||||
        <Button
 | 
			
		||||
          size="300"
 | 
			
		||||
          variant="Warning"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          onClick={requestVerification}
 | 
			
		||||
          before={requesting && <Spinner size="100" variant="Warning" fill="Solid" />}
 | 
			
		||||
          disabled={requesting}
 | 
			
		||||
        >
 | 
			
		||||
          <Text as="span" size="B300">
 | 
			
		||||
            Verify
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {requestState.status === AsyncStatus.Error && (
 | 
			
		||||
        <Text size="T200">{requestState.error.message}</Text>
 | 
			
		||||
      )}
 | 
			
		||||
      {requestState.status === AsyncStatus.Success && (
 | 
			
		||||
        <DeviceVerification request={requestState.data} onExit={handleExit} />
 | 
			
		||||
      )}
 | 
			
		||||
    </InfoCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type EnableVerificationProps = {
 | 
			
		||||
  visible: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function EnableVerification({ visible }: EnableVerificationProps) {
 | 
			
		||||
  const [open, setOpen] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleCancel = useCallback(() => setOpen(false), []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {visible && (
 | 
			
		||||
        <Button size="300" radii="300" onClick={() => setOpen(true)}>
 | 
			
		||||
          <Text as="span" size="B300">
 | 
			
		||||
            Enable
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      )}
 | 
			
		||||
      {open && (
 | 
			
		||||
        <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                clickOutsideDeactivates: false,
 | 
			
		||||
                escapeDeactivates: false,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <DeviceVerificationSetup onCancel={handleCancel} />
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function DeviceVerificationOptions() {
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const [reset, setReset] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleCancelReset = useCallback(() => {
 | 
			
		||||
    setReset(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (event) => {
 | 
			
		||||
    setMenuCords(event.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
    setReset(true);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <IconButton
 | 
			
		||||
        aria-pressed={!!menuCords}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        size="300"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        onClick={handleMenu}
 | 
			
		||||
      >
 | 
			
		||||
        <Icon size="100" src={Icons.VerticalDots} />
 | 
			
		||||
      </IconButton>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="Center"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  onClick={handleReset}
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                >
 | 
			
		||||
                  <Text as="span" size="T300" truncate>
 | 
			
		||||
                    Reset
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </MenuItem>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Menu>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {reset && (
 | 
			
		||||
        <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                clickOutsideDeactivates: false,
 | 
			
		||||
                escapeDeactivates: false,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <DeviceVerificationReset onCancel={handleCancelReset} />
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/settings/devices/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/settings/devices/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './Devices';
 | 
			
		||||
							
								
								
									
										51
									
								
								src/app/features/settings/emojis-stickers/EmojisStickers.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/app/features/settings/emojis-stickers/EmojisStickers.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,51 @@
 | 
			
		|||
import React, { useState } from 'react';
 | 
			
		||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
 | 
			
		||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { GlobalPacks } from './GlobalPacks';
 | 
			
		||||
import { UserPack } from './UserPack';
 | 
			
		||||
import { ImagePack } from '../../../plugins/custom-emoji';
 | 
			
		||||
import { ImagePackView } from '../../../components/image-pack-view';
 | 
			
		||||
 | 
			
		||||
type EmojisStickersProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
 | 
			
		||||
  const [imagePack, setImagePack] = useState<ImagePack>();
 | 
			
		||||
 | 
			
		||||
  const handleImagePackViewClose = () => {
 | 
			
		||||
    setImagePack(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (imagePack) {
 | 
			
		||||
    return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false}>
 | 
			
		||||
        <Box grow="Yes" gap="200">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Text size="H3" truncate>
 | 
			
		||||
              Emojis & Stickers
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <UserPack onViewPack={setImagePack} />
 | 
			
		||||
              <GlobalPacks onViewPack={setImagePack} />
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										488
									
								
								src/app/features/settings/emojis-stickers/GlobalPacks.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										488
									
								
								src/app/features/settings/emojis-stickers/GlobalPacks.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,488 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  Button,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Avatar,
 | 
			
		||||
  AvatarImage,
 | 
			
		||||
  AvatarFallback,
 | 
			
		||||
  config,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Menu,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  Checkbox,
 | 
			
		||||
  toRem,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Header,
 | 
			
		||||
  Line,
 | 
			
		||||
  Chip,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { useAtomValue } from 'jotai';
 | 
			
		||||
import { Room } from 'matrix-js-sdk';
 | 
			
		||||
import { useGlobalImagePacks, useRoomsImagePacks } from '../../../hooks/useImagePacks';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import {
 | 
			
		||||
  EmoteRoomsContent,
 | 
			
		||||
  ImagePack,
 | 
			
		||||
  ImageUsage,
 | 
			
		||||
  PackAddress,
 | 
			
		||||
  packAddressEqual,
 | 
			
		||||
} from '../../../plugins/custom-emoji';
 | 
			
		||||
import { LineClamp2 } from '../../../styles/Text.css';
 | 
			
		||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
 | 
			
		||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
function GlobalPackSelector({
 | 
			
		||||
  packs,
 | 
			
		||||
  useAuthentication,
 | 
			
		||||
  onSelect,
 | 
			
		||||
}: {
 | 
			
		||||
  packs: ImagePack[];
 | 
			
		||||
  useAuthentication: boolean;
 | 
			
		||||
  onSelect: (addresses: PackAddress[]) => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const roomToPacks = useMemo(() => {
 | 
			
		||||
    const rToP = new Map<string, ImagePack[]>();
 | 
			
		||||
    packs
 | 
			
		||||
      .filter((pack) => !pack.deleted)
 | 
			
		||||
      .forEach((pack) => {
 | 
			
		||||
        if (!pack.address) return;
 | 
			
		||||
        const pks = rToP.get(pack.address.roomId) ?? [];
 | 
			
		||||
        pks.push(pack);
 | 
			
		||||
        rToP.set(pack.address.roomId, pks);
 | 
			
		||||
      });
 | 
			
		||||
    return rToP;
 | 
			
		||||
  }, [packs]);
 | 
			
		||||
 | 
			
		||||
  const [selected, setSelected] = useState<PackAddress[]>([]);
 | 
			
		||||
  const toggleSelect = (address: PackAddress) => {
 | 
			
		||||
    setSelected((addresses) => {
 | 
			
		||||
      const newAddresses = addresses.filter((addr) => !packAddressEqual(addr, address));
 | 
			
		||||
      if (newAddresses.length !== addresses.length) {
 | 
			
		||||
        return newAddresses;
 | 
			
		||||
      }
 | 
			
		||||
      newAddresses.push(address);
 | 
			
		||||
      return newAddresses;
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const hasSelected = selected.length > 0;
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" direction="Column">
 | 
			
		||||
      <Header size="400" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
 | 
			
		||||
        <Box grow="Yes">
 | 
			
		||||
          <Text size="L400" truncate>
 | 
			
		||||
            Room Packs
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No">
 | 
			
		||||
          <Chip
 | 
			
		||||
            radii="Pill"
 | 
			
		||||
            variant={hasSelected ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
            outlined={hasSelected}
 | 
			
		||||
            onClick={() => onSelect(selected)}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">{hasSelected ? 'Save' : 'Close'}</Text>
 | 
			
		||||
          </Chip>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Header>
 | 
			
		||||
      <Line variant="Surface" size="300" />
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll size="300" hideTrack visibility="Hover">
 | 
			
		||||
          <Box
 | 
			
		||||
            direction="Column"
 | 
			
		||||
            gap="400"
 | 
			
		||||
            style={{
 | 
			
		||||
              paddingLeft: config.space.S300,
 | 
			
		||||
              paddingTop: config.space.S300,
 | 
			
		||||
              paddingBottom: config.space.S300,
 | 
			
		||||
              paddingRight: config.space.S100,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            {Array.from(roomToPacks.entries()).map(([roomId, roomPacks]) => {
 | 
			
		||||
              const room = mx.getRoom(roomId);
 | 
			
		||||
              if (!room) return null;
 | 
			
		||||
              return (
 | 
			
		||||
                <Box key={roomId} direction="Column" gap="100">
 | 
			
		||||
                  <Text size="L400">{room.name}</Text>
 | 
			
		||||
                  {roomPacks.map((pack) => {
 | 
			
		||||
                    const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
 | 
			
		||||
                    const avatarUrl = avatarMxc
 | 
			
		||||
                      ? mxcUrlToHttp(mx, avatarMxc, useAuthentication)
 | 
			
		||||
                      : undefined;
 | 
			
		||||
                    const { address } = pack;
 | 
			
		||||
                    if (!address) return null;
 | 
			
		||||
 | 
			
		||||
                    const added = selected.find((addr) => packAddressEqual(addr, address));
 | 
			
		||||
                    return (
 | 
			
		||||
                      <SequenceCard
 | 
			
		||||
                        key={pack.id}
 | 
			
		||||
                        className={SequenceCardStyle}
 | 
			
		||||
                        variant={added ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
                        direction="Column"
 | 
			
		||||
                        gap="400"
 | 
			
		||||
                      >
 | 
			
		||||
                        <SettingTile
 | 
			
		||||
                          title={pack.meta.name ?? 'Unknown'}
 | 
			
		||||
                          description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
 | 
			
		||||
                          before={
 | 
			
		||||
                            <Box alignItems="Center" gap="300">
 | 
			
		||||
                              <Avatar size="300" radii="300">
 | 
			
		||||
                                {avatarUrl ? (
 | 
			
		||||
                                  <AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
 | 
			
		||||
                                ) : (
 | 
			
		||||
                                  <AvatarFallback>
 | 
			
		||||
                                    <Icon size="400" src={Icons.Sticker} filled />
 | 
			
		||||
                                  </AvatarFallback>
 | 
			
		||||
                                )}
 | 
			
		||||
                              </Avatar>
 | 
			
		||||
                            </Box>
 | 
			
		||||
                          }
 | 
			
		||||
                          after={
 | 
			
		||||
                            <Checkbox variant="Success" onClick={() => toggleSelect(address)} />
 | 
			
		||||
                          }
 | 
			
		||||
                        />
 | 
			
		||||
                      </SequenceCard>
 | 
			
		||||
                    );
 | 
			
		||||
                  })}
 | 
			
		||||
                </Box>
 | 
			
		||||
              );
 | 
			
		||||
            })}
 | 
			
		||||
 | 
			
		||||
            {roomToPacks.size === 0 && (
 | 
			
		||||
              <SequenceCard
 | 
			
		||||
                className={SequenceCardStyle}
 | 
			
		||||
                variant="SurfaceVariant"
 | 
			
		||||
                direction="Column"
 | 
			
		||||
                gap="400"
 | 
			
		||||
              >
 | 
			
		||||
                <Box
 | 
			
		||||
                  justifyContent="Center"
 | 
			
		||||
                  direction="Column"
 | 
			
		||||
                  gap="200"
 | 
			
		||||
                  style={{
 | 
			
		||||
                    padding: `${config.space.S700} ${config.space.S400}`,
 | 
			
		||||
                    maxWidth: toRem(300),
 | 
			
		||||
                    margin: 'auto',
 | 
			
		||||
                  }}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="H5" align="Center">
 | 
			
		||||
                    No Packs
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T200" align="Center">
 | 
			
		||||
                    Pack from rooms will appear here. You do not have any room with packs yet.
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </SequenceCard>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type GlobalPacksProps = {
 | 
			
		||||
  onViewPack: (imagePack: ImagePack) => void;
 | 
			
		||||
};
 | 
			
		||||
export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const globalPacks = useGlobalImagePacks();
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const roomIds = useAtomValue(allRoomsAtom);
 | 
			
		||||
  const rooms = useMemo(() => {
 | 
			
		||||
    const rs: Room[] = [];
 | 
			
		||||
    roomIds.forEach((rId) => {
 | 
			
		||||
      const r = mx.getRoom(rId);
 | 
			
		||||
      if (r) rs.push(r);
 | 
			
		||||
    });
 | 
			
		||||
    return rs;
 | 
			
		||||
  }, [mx, roomIds]);
 | 
			
		||||
  const roomsImagePack = useRoomsImagePacks(rooms);
 | 
			
		||||
  const nonGlobalPacks = useMemo(
 | 
			
		||||
    () =>
 | 
			
		||||
      roomsImagePack.filter(
 | 
			
		||||
        (pack) => !globalPacks.find((p) => packAddressEqual(pack.address, p.address))
 | 
			
		||||
      ),
 | 
			
		||||
    [roomsImagePack, globalPacks]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [selectedPacks, setSelectedPacks] = useState<PackAddress[]>([]);
 | 
			
		||||
  const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
 | 
			
		||||
 | 
			
		||||
  const unselectedGlobalPacks = useMemo(
 | 
			
		||||
    () =>
 | 
			
		||||
      nonGlobalPacks.filter(
 | 
			
		||||
        (pack) => !selectedPacks.find((addr) => packAddressEqual(pack.address, addr))
 | 
			
		||||
      ),
 | 
			
		||||
    [selectedPacks, nonGlobalPacks]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRemove = (address: PackAddress) => {
 | 
			
		||||
    setRemovedPacks((addresses) => [...addresses, address]);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleUndoRemove = (address: PackAddress) => {
 | 
			
		||||
    setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSelected = (addresses: PackAddress[]) => {
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
    if (addresses.length > 0) {
 | 
			
		||||
      setSelectedPacks((a) => [...addresses, ...a]);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const [applyState, applyChanges] = useAsyncCallback(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      const content =
 | 
			
		||||
        mx.getAccountData(AccountDataEvent.PoniesEmoteRooms)?.getContent<EmoteRoomsContent>() ?? {};
 | 
			
		||||
      const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content));
 | 
			
		||||
 | 
			
		||||
      selectedPacks.forEach((addr) => {
 | 
			
		||||
        const roomsToState = updatedContent.rooms ?? {};
 | 
			
		||||
        const stateKeyToObj = roomsToState[addr.roomId] ?? {};
 | 
			
		||||
        stateKeyToObj[addr.stateKey] = {};
 | 
			
		||||
        roomsToState[addr.roomId] = stateKeyToObj;
 | 
			
		||||
        updatedContent.rooms = roomsToState;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      removedPacks.forEach((addr) => {
 | 
			
		||||
        if (updatedContent.rooms?.[addr.roomId]?.[addr.stateKey]) {
 | 
			
		||||
          delete updatedContent.rooms?.[addr.roomId][addr.stateKey];
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await mx.setAccountData(AccountDataEvent.PoniesEmoteRooms, updatedContent);
 | 
			
		||||
    }, [mx, selectedPacks, removedPacks])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const resetChanges = useCallback(() => {
 | 
			
		||||
    setSelectedPacks([]);
 | 
			
		||||
    setRemovedPacks([]);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (applyState.status === AsyncStatus.Success) {
 | 
			
		||||
      resetChanges();
 | 
			
		||||
    }
 | 
			
		||||
  }, [applyState, resetChanges]);
 | 
			
		||||
 | 
			
		||||
  const handleSelectMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const applyingChanges = applyState.status === AsyncStatus.Loading;
 | 
			
		||||
  const hasChanges = removedPacks.length > 0 || selectedPacks.length > 0;
 | 
			
		||||
 | 
			
		||||
  const renderPack = (pack: ImagePack) => {
 | 
			
		||||
    const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
 | 
			
		||||
    const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
 | 
			
		||||
    const { address } = pack;
 | 
			
		||||
    if (!address) return null;
 | 
			
		||||
    const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        key={pack.id}
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant={removed ? 'Critical' : 'SurfaceVariant'}
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title={
 | 
			
		||||
            <span style={{ textDecoration: removed ? 'line-through' : undefined }}>
 | 
			
		||||
              {pack.meta.name ?? 'Unknown'}
 | 
			
		||||
            </span>
 | 
			
		||||
          }
 | 
			
		||||
          description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
 | 
			
		||||
          before={
 | 
			
		||||
            <Box alignItems="Center" gap="300">
 | 
			
		||||
              {removed ? (
 | 
			
		||||
                <IconButton
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  radii="Pill"
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  onClick={() => handleUndoRemove(address)}
 | 
			
		||||
                  disabled={applyingChanges}
 | 
			
		||||
                >
 | 
			
		||||
                  <Icon src={Icons.Plus} size="100" />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <IconButton
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  radii="Pill"
 | 
			
		||||
                  variant="Secondary"
 | 
			
		||||
                  onClick={() => handleRemove(address)}
 | 
			
		||||
                  disabled={applyingChanges}
 | 
			
		||||
                >
 | 
			
		||||
                  <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              )}
 | 
			
		||||
              <Avatar size="300" radii="300">
 | 
			
		||||
                {avatarUrl ? (
 | 
			
		||||
                  <AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <AvatarFallback>
 | 
			
		||||
                    <Icon size="400" src={Icons.Sticker} filled />
 | 
			
		||||
                  </AvatarFallback>
 | 
			
		||||
                )}
 | 
			
		||||
              </Avatar>
 | 
			
		||||
            </Box>
 | 
			
		||||
          }
 | 
			
		||||
          after={
 | 
			
		||||
            !removed && (
 | 
			
		||||
              <Button
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
                fill="Soft"
 | 
			
		||||
                size="300"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                outlined
 | 
			
		||||
                onClick={() => onViewPack(pack)}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">View</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Box direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Favorite Packs</Text>
 | 
			
		||||
        <SequenceCard
 | 
			
		||||
          className={SequenceCardStyle}
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          direction="Column"
 | 
			
		||||
          gap="400"
 | 
			
		||||
        >
 | 
			
		||||
          <SettingTile
 | 
			
		||||
            title="Select Pack"
 | 
			
		||||
            description="Pick emojis and stickers pack from rooms to use in all rooms."
 | 
			
		||||
            after={
 | 
			
		||||
              <>
 | 
			
		||||
                <Button
 | 
			
		||||
                  onClick={handleSelectMenu}
 | 
			
		||||
                  variant="Secondary"
 | 
			
		||||
                  fill="Soft"
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  outlined
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">Select</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
                <PopOut
 | 
			
		||||
                  anchor={menuCords}
 | 
			
		||||
                  position="Bottom"
 | 
			
		||||
                  align="End"
 | 
			
		||||
                  content={
 | 
			
		||||
                    <FocusTrap
 | 
			
		||||
                      focusTrapOptions={{
 | 
			
		||||
                        initialFocus: false,
 | 
			
		||||
                        onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
                        clickOutsideDeactivates: true,
 | 
			
		||||
                        isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                          evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                        isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                          evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                        escapeDeactivates: stopPropagation,
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Menu
 | 
			
		||||
                        style={{
 | 
			
		||||
                          display: 'flex',
 | 
			
		||||
                          maxWidth: toRem(400),
 | 
			
		||||
                          width: '100vw',
 | 
			
		||||
                          maxHeight: toRem(500),
 | 
			
		||||
                        }}
 | 
			
		||||
                      >
 | 
			
		||||
                        <GlobalPackSelector
 | 
			
		||||
                          packs={unselectedGlobalPacks}
 | 
			
		||||
                          useAuthentication={useAuthentication}
 | 
			
		||||
                          onSelect={handleSelected}
 | 
			
		||||
                        />
 | 
			
		||||
                      </Menu>
 | 
			
		||||
                    </FocusTrap>
 | 
			
		||||
                  }
 | 
			
		||||
                />
 | 
			
		||||
              </>
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </SequenceCard>
 | 
			
		||||
        {globalPacks.map(renderPack)}
 | 
			
		||||
        {nonGlobalPacks
 | 
			
		||||
          .filter((pack) => !!selectedPacks.find((addr) => packAddressEqual(pack.address, addr)))
 | 
			
		||||
          .map(renderPack)}
 | 
			
		||||
      </Box>
 | 
			
		||||
      {hasChanges && (
 | 
			
		||||
        <Menu
 | 
			
		||||
          style={{
 | 
			
		||||
            position: 'sticky',
 | 
			
		||||
            padding: config.space.S200,
 | 
			
		||||
            paddingLeft: config.space.S400,
 | 
			
		||||
            bottom: config.space.S400,
 | 
			
		||||
            left: config.space.S400,
 | 
			
		||||
            right: 0,
 | 
			
		||||
            zIndex: 1,
 | 
			
		||||
          }}
 | 
			
		||||
          variant="Success"
 | 
			
		||||
        >
 | 
			
		||||
          <Box alignItems="Center" gap="400">
 | 
			
		||||
            <Box grow="Yes" direction="Column">
 | 
			
		||||
              {applyState.status === AsyncStatus.Error ? (
 | 
			
		||||
                <Text size="T200">
 | 
			
		||||
                  <b>Failed to apply changes! Please try again.</b>
 | 
			
		||||
                </Text>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <Text size="T200">
 | 
			
		||||
                  <b>Changes saved! Apply when ready.</b>
 | 
			
		||||
                </Text>
 | 
			
		||||
              )}
 | 
			
		||||
            </Box>
 | 
			
		||||
            <Box shrink="No" gap="200">
 | 
			
		||||
              <Button
 | 
			
		||||
                size="300"
 | 
			
		||||
                variant="Success"
 | 
			
		||||
                fill="None"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                disabled={applyingChanges}
 | 
			
		||||
                onClick={resetChanges}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">Reset</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
              <Button
 | 
			
		||||
                size="300"
 | 
			
		||||
                variant="Success"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                disabled={applyingChanges}
 | 
			
		||||
                before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
 | 
			
		||||
                onClick={applyChanges}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">Apply Changes</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Menu>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										71
									
								
								src/app/features/settings/emojis-stickers/UserPack.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/app/features/settings/emojis-stickers/UserPack.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Icon, Icons, Text } from 'folds';
 | 
			
		||||
import { useUserImagePack } from '../../../hooks/useImagePacks';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
 | 
			
		||||
type UserPackProps = {
 | 
			
		||||
  onViewPack: (imagePack: ImagePack) => void;
 | 
			
		||||
};
 | 
			
		||||
export function UserPack({ onViewPack }: UserPackProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
 | 
			
		||||
  const userPack = useUserImagePack();
 | 
			
		||||
  const avatarMxc = userPack?.getAvatarUrl(ImageUsage.Emoticon);
 | 
			
		||||
  const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
 | 
			
		||||
 | 
			
		||||
  const handleView = () => {
 | 
			
		||||
    if (userPack) {
 | 
			
		||||
      onViewPack(userPack);
 | 
			
		||||
    } else {
 | 
			
		||||
      const defaultPack = new ImagePack(mx.getUserId() ?? '', {}, undefined);
 | 
			
		||||
      onViewPack(defaultPack);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Default Pack</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title={userPack?.meta.name ?? 'Unknown'}
 | 
			
		||||
          description={userPack?.meta.attribution}
 | 
			
		||||
          before={
 | 
			
		||||
            <Avatar size="300" radii="300">
 | 
			
		||||
              {avatarUrl ? (
 | 
			
		||||
                <AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
 | 
			
		||||
              ) : (
 | 
			
		||||
                <AvatarFallback>
 | 
			
		||||
                  <Icon size="400" src={Icons.Sticker} filled />
 | 
			
		||||
                </AvatarFallback>
 | 
			
		||||
              )}
 | 
			
		||||
            </Avatar>
 | 
			
		||||
          }
 | 
			
		||||
          after={
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              outlined
 | 
			
		||||
              onClick={handleView}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">View</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/settings/emojis-stickers/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/settings/emojis-stickers/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './EmojisStickers';
 | 
			
		||||
							
								
								
									
										618
									
								
								src/app/features/settings/general/General.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										618
									
								
								src/app/features/settings/general/General.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,618 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  ChangeEventHandler,
 | 
			
		||||
  KeyboardEventHandler,
 | 
			
		||||
  MouseEventHandler,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  as,
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Input,
 | 
			
		||||
  Menu,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Switch,
 | 
			
		||||
  Text,
 | 
			
		||||
  toRem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { isKeyHotkey } from 'is-hotkey';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { useSetting } from '../../../state/hooks/settings';
 | 
			
		||||
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { KeySymbol } from '../../../utils/key-symbol';
 | 
			
		||||
import { isMacOS } from '../../../utils/user-agent';
 | 
			
		||||
import {
 | 
			
		||||
  DarkTheme,
 | 
			
		||||
  LightTheme,
 | 
			
		||||
  Theme,
 | 
			
		||||
  ThemeKind,
 | 
			
		||||
  useSystemThemeKind,
 | 
			
		||||
  useThemeNames,
 | 
			
		||||
  useThemes,
 | 
			
		||||
} from '../../../hooks/useTheme';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
 | 
			
		||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
 | 
			
		||||
type ThemeSelectorProps = {
 | 
			
		||||
  themeNames: Record<string, string>;
 | 
			
		||||
  themes: Theme[];
 | 
			
		||||
  selected: Theme;
 | 
			
		||||
  onSelect: (theme: Theme) => void;
 | 
			
		||||
};
 | 
			
		||||
const ThemeSelector = as<'div', ThemeSelectorProps>(
 | 
			
		||||
  ({ themeNames, themes, selected, onSelect, ...props }, ref) => (
 | 
			
		||||
    <Menu {...props} ref={ref}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
        {themes.map((theme) => (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            key={theme.id}
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant={theme.id === selected.id ? 'Primary' : 'Surface'}
 | 
			
		||||
            radii="300"
 | 
			
		||||
            onClick={() => onSelect(theme)}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
 | 
			
		||||
          </MenuItem>
 | 
			
		||||
        ))}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Menu>
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function SelectTheme({ disabled }: { disabled?: boolean }) {
 | 
			
		||||
  const themes = useThemes();
 | 
			
		||||
  const themeNames = useThemeNames();
 | 
			
		||||
  const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
  const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
 | 
			
		||||
 | 
			
		||||
  const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleThemeSelect = (theme: Theme) => {
 | 
			
		||||
    setThemeId(theme.id);
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Button
 | 
			
		||||
        size="300"
 | 
			
		||||
        variant="Primary"
 | 
			
		||||
        outlined
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        after={<Icon size="300" src={Icons.ChevronBottom} />}
 | 
			
		||||
        onClick={disabled ? undefined : handleThemeMenu}
 | 
			
		||||
        aria-disabled={disabled}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="T300">{themeNames[selectedTheme.id] ?? selectedTheme.id}</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <ThemeSelector
 | 
			
		||||
              themeNames={themeNames}
 | 
			
		||||
              themes={themes}
 | 
			
		||||
              selected={selectedTheme}
 | 
			
		||||
              onSelect={handleThemeSelect}
 | 
			
		||||
            />
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SystemThemePreferences() {
 | 
			
		||||
  const themeKind = useSystemThemeKind();
 | 
			
		||||
  const themeNames = useThemeNames();
 | 
			
		||||
  const themes = useThemes();
 | 
			
		||||
  const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
 | 
			
		||||
  const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
 | 
			
		||||
 | 
			
		||||
  const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light);
 | 
			
		||||
  const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark);
 | 
			
		||||
 | 
			
		||||
  const selectedLightTheme = lightThemes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
 | 
			
		||||
  const selectedDarkTheme = darkThemes.find((theme) => theme.id === darkThemeId) ?? DarkTheme;
 | 
			
		||||
 | 
			
		||||
  const [ltCords, setLTCords] = useState<RectCords>();
 | 
			
		||||
  const [dtCords, setDTCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleLightThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setLTCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
  const handleDarkThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setDTCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleLightThemeSelect = (theme: Theme) => {
 | 
			
		||||
    setLightThemeId(theme.id);
 | 
			
		||||
    setLTCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDarkThemeSelect = (theme: Theme) => {
 | 
			
		||||
    setDarkThemeId(theme.id);
 | 
			
		||||
    setDTCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box wrap="Wrap" gap="400">
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Light Theme:"
 | 
			
		||||
        after={
 | 
			
		||||
          <Chip
 | 
			
		||||
            variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
 | 
			
		||||
            outlined={themeKind === ThemeKind.Light}
 | 
			
		||||
            radii="Pill"
 | 
			
		||||
            after={<Icon size="200" src={Icons.ChevronBottom} />}
 | 
			
		||||
            onClick={handleLightThemeMenu}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">{themeNames[selectedLightTheme.id] ?? selectedLightTheme.id}</Text>
 | 
			
		||||
          </Chip>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={ltCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setLTCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <ThemeSelector
 | 
			
		||||
              themeNames={themeNames}
 | 
			
		||||
              themes={lightThemes}
 | 
			
		||||
              selected={selectedLightTheme}
 | 
			
		||||
              onSelect={handleLightThemeSelect}
 | 
			
		||||
            />
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Dark Theme:"
 | 
			
		||||
        after={
 | 
			
		||||
          <Chip
 | 
			
		||||
            variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
 | 
			
		||||
            outlined={themeKind === ThemeKind.Dark}
 | 
			
		||||
            radii="Pill"
 | 
			
		||||
            after={<Icon size="200" src={Icons.ChevronBottom} />}
 | 
			
		||||
            onClick={handleDarkThemeMenu}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">{themeNames[selectedDarkTheme.id] ?? selectedDarkTheme.id}</Text>
 | 
			
		||||
          </Chip>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={dtCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setDTCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <ThemeSelector
 | 
			
		||||
              themeNames={themeNames}
 | 
			
		||||
              themes={darkThemes}
 | 
			
		||||
              selected={selectedDarkTheme}
 | 
			
		||||
              onSelect={handleDarkThemeSelect}
 | 
			
		||||
            />
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function PageZoomInput() {
 | 
			
		||||
  const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom');
 | 
			
		||||
  const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`);
 | 
			
		||||
 | 
			
		||||
  const handleZoomChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    setCurrentZoom(evt.target.value);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    if (isKeyHotkey('escape', evt)) {
 | 
			
		||||
      evt.stopPropagation();
 | 
			
		||||
      setCurrentZoom(pageZoom.toString());
 | 
			
		||||
    }
 | 
			
		||||
    if (
 | 
			
		||||
      isKeyHotkey('enter', evt) &&
 | 
			
		||||
      'value' in evt.target &&
 | 
			
		||||
      typeof evt.target.value === 'string'
 | 
			
		||||
    ) {
 | 
			
		||||
      const newZoom = parseInt(evt.target.value, 10);
 | 
			
		||||
      if (Number.isNaN(newZoom)) return;
 | 
			
		||||
      const safeZoom = Math.max(Math.min(newZoom, 150), 75);
 | 
			
		||||
      setPageZoom(safeZoom);
 | 
			
		||||
      setCurrentZoom(safeZoom.toString());
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Input
 | 
			
		||||
      style={{ width: toRem(100) }}
 | 
			
		||||
      variant={pageZoom === parseInt(currentZoom, 10) ? 'Secondary' : 'Success'}
 | 
			
		||||
      size="300"
 | 
			
		||||
      radii="300"
 | 
			
		||||
      type="number"
 | 
			
		||||
      min="75"
 | 
			
		||||
      max="150"
 | 
			
		||||
      value={currentZoom}
 | 
			
		||||
      onChange={handleZoomChange}
 | 
			
		||||
      onKeyDown={handleZoomEnter}
 | 
			
		||||
      after={<Text size="T300">%</Text>}
 | 
			
		||||
      outlined
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Appearance() {
 | 
			
		||||
  const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
 | 
			
		||||
  const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Appearance</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="System Theme"
 | 
			
		||||
          description="Choose between light and dark theme based on system preference."
 | 
			
		||||
          after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
 | 
			
		||||
        />
 | 
			
		||||
        {systemTheme && <SystemThemePreferences />}
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Theme"
 | 
			
		||||
          description="Theme to use when system theme is not enabled."
 | 
			
		||||
          after={<SelectTheme disabled={systemTheme} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Twitter Emoji"
 | 
			
		||||
          after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile title="Page Zoom" after={<PageZoomInput />} />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Editor() {
 | 
			
		||||
  const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
 | 
			
		||||
  const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Editor</Text>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="ENTER for Newline"
 | 
			
		||||
          description={`Use ${
 | 
			
		||||
            isMacOS() ? KeySymbol.Command : 'Ctrl'
 | 
			
		||||
          } + ENTER to send message and ENTER for newline.`}
 | 
			
		||||
          after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Markdown Formatting"
 | 
			
		||||
          after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectMessageLayout() {
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
  const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
 | 
			
		||||
  const messageLayoutItems = useMessageLayoutItems();
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (layout: MessageLayout) => {
 | 
			
		||||
    setMessageLayout(layout);
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Button
 | 
			
		||||
        size="300"
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        outlined
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        after={<Icon size="300" src={Icons.ChevronBottom} />}
 | 
			
		||||
        onClick={handleMenu}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="T300">
 | 
			
		||||
          {messageLayoutItems.find((i) => i.layout === messageLayout)?.name ?? messageLayout}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                {messageLayoutItems.map((item) => (
 | 
			
		||||
                  <MenuItem
 | 
			
		||||
                    key={item.layout}
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    variant={messageLayout === item.layout ? 'Primary' : 'Surface'}
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    onClick={() => handleSelect(item.layout)}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="T300">{item.name}</Text>
 | 
			
		||||
                  </MenuItem>
 | 
			
		||||
                ))}
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Menu>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectMessageSpacing() {
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
  const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
 | 
			
		||||
  const messageSpacingItems = useMessageSpacingItems();
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (layout: MessageSpacing) => {
 | 
			
		||||
    setMessageSpacing(layout);
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Button
 | 
			
		||||
        size="300"
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        outlined
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        after={<Icon size="300" src={Icons.ChevronBottom} />}
 | 
			
		||||
        onClick={handleMenu}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="T300">
 | 
			
		||||
          {messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                {messageSpacingItems.map((item) => (
 | 
			
		||||
                  <MenuItem
 | 
			
		||||
                    key={item.spacing}
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    variant={messageSpacing === item.spacing ? 'Primary' : 'Surface'}
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    onClick={() => handleSelect(item.spacing)}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="T300">{item.name}</Text>
 | 
			
		||||
                  </MenuItem>
 | 
			
		||||
                ))}
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Menu>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Messages() {
 | 
			
		||||
  const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
 | 
			
		||||
    settingsAtom,
 | 
			
		||||
    'hideMembershipEvents'
 | 
			
		||||
  );
 | 
			
		||||
  const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(
 | 
			
		||||
    settingsAtom,
 | 
			
		||||
    'hideNickAvatarEvents'
 | 
			
		||||
  );
 | 
			
		||||
  const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
 | 
			
		||||
  const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
 | 
			
		||||
  const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
 | 
			
		||||
  const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Messages</Text>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile title="Message Layout" after={<SelectMessageLayout />} />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Hide Membership Change"
 | 
			
		||||
          after={
 | 
			
		||||
            <Switch
 | 
			
		||||
              variant="Primary"
 | 
			
		||||
              value={hideMembershipEvents}
 | 
			
		||||
              onChange={setHideMembershipEvents}
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Hide Profile Change"
 | 
			
		||||
          after={
 | 
			
		||||
            <Switch
 | 
			
		||||
              variant="Primary"
 | 
			
		||||
              value={hideNickAvatarEvents}
 | 
			
		||||
              onChange={setHideNickAvatarEvents}
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Disable Media Auto Load"
 | 
			
		||||
          after={<Switch variant="Primary" value={mediaAutoLoad} onChange={setMediaAutoLoad} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Url Preview"
 | 
			
		||||
          after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Url Preview in Encrypted Room"
 | 
			
		||||
          after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Show Hidden Events"
 | 
			
		||||
          after={
 | 
			
		||||
            <Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type GeneralProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function General({ requestClose }: GeneralProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false}>
 | 
			
		||||
        <Box grow="Yes" gap="200">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Text size="H3" truncate>
 | 
			
		||||
              General
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <Appearance />
 | 
			
		||||
              <Editor />
 | 
			
		||||
              <Messages />
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/settings/general/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/settings/general/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './General';
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/settings/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/settings/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './Settings';
 | 
			
		||||
							
								
								
									
										152
									
								
								src/app/features/settings/notifications/AllMessages.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								src/app/features/settings/notifications/AllMessages.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,152 @@
 | 
			
		|||
import React, { useCallback, useMemo } from 'react';
 | 
			
		||||
import { Badge, Box, Text } from 'folds';
 | 
			
		||||
import { ConditionKind, IPushRules, PushRuleCondition, PushRuleKind, RuleId } from 'matrix-js-sdk';
 | 
			
		||||
import { useAccountData } from '../../../hooks/useAccountData';
 | 
			
		||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
 | 
			
		||||
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { PushRuleData, usePushRule } from '../../../hooks/usePushRule';
 | 
			
		||||
import {
 | 
			
		||||
  getNotificationModeActions,
 | 
			
		||||
  NotificationMode,
 | 
			
		||||
  useNotificationModeActions,
 | 
			
		||||
} from '../../../hooks/useNotificationMode';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
 | 
			
		||||
const getAllMessageDefaultRule = (
 | 
			
		||||
  ruleId: RuleId,
 | 
			
		||||
  encrypted: boolean,
 | 
			
		||||
  oneToOne: boolean
 | 
			
		||||
): PushRuleData => {
 | 
			
		||||
  const conditions: PushRuleCondition[] = [];
 | 
			
		||||
  if (oneToOne)
 | 
			
		||||
    conditions.push({
 | 
			
		||||
      kind: ConditionKind.RoomMemberCount,
 | 
			
		||||
      is: '2',
 | 
			
		||||
    });
 | 
			
		||||
  conditions.push({
 | 
			
		||||
    kind: ConditionKind.EventMatch,
 | 
			
		||||
    key: 'type',
 | 
			
		||||
    pattern: encrypted ? 'm.room.encrypted' : 'm.room.message',
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    kind: PushRuleKind.Underride,
 | 
			
		||||
    pushRule: {
 | 
			
		||||
      rule_id: ruleId,
 | 
			
		||||
      default: true,
 | 
			
		||||
      enabled: true,
 | 
			
		||||
      conditions,
 | 
			
		||||
      actions: getNotificationModeActions(NotificationMode.NotifyLoud),
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type PushRulesProps = {
 | 
			
		||||
  ruleId: RuleId.DM | RuleId.EncryptedDM | RuleId.Message | RuleId.EncryptedMessage;
 | 
			
		||||
  pushRules: IPushRules;
 | 
			
		||||
  encrypted?: boolean;
 | 
			
		||||
  oneToOne?: boolean;
 | 
			
		||||
};
 | 
			
		||||
function AllMessagesModeSwitcher({
 | 
			
		||||
  ruleId,
 | 
			
		||||
  pushRules,
 | 
			
		||||
  encrypted = false,
 | 
			
		||||
  oneToOne = false,
 | 
			
		||||
}: PushRulesProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const defaultPushRuleData = getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
 | 
			
		||||
  const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
 | 
			
		||||
  const getModeActions = useNotificationModeActions();
 | 
			
		||||
 | 
			
		||||
  const handleChange = useCallback(
 | 
			
		||||
    async (mode: NotificationMode) => {
 | 
			
		||||
      const actions = getModeActions(mode);
 | 
			
		||||
      await mx.setPushRuleActions('global', kind, ruleId, actions);
 | 
			
		||||
    },
 | 
			
		||||
    [mx, getModeActions, kind, ruleId]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function AllMessagesNotifications() {
 | 
			
		||||
  const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
 | 
			
		||||
  const pushRules = useMemo(
 | 
			
		||||
    () => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
 | 
			
		||||
    [pushRulesEvt]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
 | 
			
		||||
        <Text size="L400">All Messages</Text>
 | 
			
		||||
        <Box gap="100">
 | 
			
		||||
          <Text size="T200">Badge: </Text>
 | 
			
		||||
          <Badge radii="300" variant="Secondary" fill="Solid">
 | 
			
		||||
            <Text size="L400">1</Text>
 | 
			
		||||
          </Badge>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="1-to-1 Chats"
 | 
			
		||||
          after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="1-to-1 Chats (Encrypted)"
 | 
			
		||||
          after={
 | 
			
		||||
            <AllMessagesModeSwitcher
 | 
			
		||||
              pushRules={pushRules}
 | 
			
		||||
              ruleId={RuleId.EncryptedDM}
 | 
			
		||||
              encrypted
 | 
			
		||||
              oneToOne
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Rooms"
 | 
			
		||||
          after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.Message} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Rooms (Encrypted)"
 | 
			
		||||
          after={
 | 
			
		||||
            <AllMessagesModeSwitcher
 | 
			
		||||
              pushRules={pushRules}
 | 
			
		||||
              ruleId={RuleId.EncryptedMessage}
 | 
			
		||||
              encrypted
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										171
									
								
								src/app/features/settings/notifications/IgnoredUserList.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								src/app/features/settings/notifications/IgnoredUserList.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,171 @@
 | 
			
		|||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds';
 | 
			
		||||
import { useAccountData } from '../../../hooks/useAccountData';
 | 
			
		||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { isUserId } from '../../../utils/matrix';
 | 
			
		||||
 | 
			
		||||
type IgnoredUserListContent = {
 | 
			
		||||
  ignored_users?: Record<string, object>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function IgnoreUserInput({ userList }: { userList: string[] }) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [userId, setUserId] = useState<string>('');
 | 
			
		||||
 | 
			
		||||
  const [ignoreState, ignore] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (uId: string) => {
 | 
			
		||||
        mx.setIgnoredUsers([...userList, uId]);
 | 
			
		||||
        setUserId('');
 | 
			
		||||
      },
 | 
			
		||||
      [mx, userList]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
  const ignoring = ignoreState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const uId = evt.currentTarget.value;
 | 
			
		||||
    setUserId(uId);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setUserId('');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (ignoring) return;
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const userIdInput = target?.userIdInput as HTMLInputElement | undefined;
 | 
			
		||||
    const uId = userIdInput?.value.trim();
 | 
			
		||||
    if (!uId) return;
 | 
			
		||||
 | 
			
		||||
    if (!isUserId(uId)) return;
 | 
			
		||||
 | 
			
		||||
    ignore(uId);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={ignoring}>
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
        <Input
 | 
			
		||||
          required
 | 
			
		||||
          name="userIdInput"
 | 
			
		||||
          value={userId}
 | 
			
		||||
          onChange={handleChange}
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          style={{ paddingRight: config.space.S200 }}
 | 
			
		||||
          readOnly={ignoring}
 | 
			
		||||
          after={
 | 
			
		||||
            userId &&
 | 
			
		||||
            !ignoring && (
 | 
			
		||||
              <IconButton
 | 
			
		||||
                type="reset"
 | 
			
		||||
                onClick={handleReset}
 | 
			
		||||
                size="300"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
              >
 | 
			
		||||
                <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Button
 | 
			
		||||
        size="400"
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        outlined
 | 
			
		||||
        radii="300"
 | 
			
		||||
        type="submit"
 | 
			
		||||
        disabled={ignoring}
 | 
			
		||||
      >
 | 
			
		||||
        {ignoring && <Spinner variant="Secondary" size="300" />}
 | 
			
		||||
        <Text size="B400">Block</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function IgnoredUserChip({ userId, userList }: { userId: string; userList: string[] }) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [unignoreState, unignore] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      () => mx.setIgnoredUsers(userList.filter((uId) => uId !== userId)),
 | 
			
		||||
      [mx, userId, userList]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleUnignore = () => unignore();
 | 
			
		||||
 | 
			
		||||
  const unIgnoring = unignoreState.status === AsyncStatus.Loading;
 | 
			
		||||
  return (
 | 
			
		||||
    <Chip
 | 
			
		||||
      variant="Secondary"
 | 
			
		||||
      radii="Pill"
 | 
			
		||||
      after={
 | 
			
		||||
        unIgnoring ? (
 | 
			
		||||
          <Spinner variant="Secondary" size="100" />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
      onClick={handleUnignore}
 | 
			
		||||
      disabled={unIgnoring}
 | 
			
		||||
    >
 | 
			
		||||
      <Text size="T200" truncate>
 | 
			
		||||
        {userId}
 | 
			
		||||
      </Text>
 | 
			
		||||
    </Chip>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function IgnoredUserList() {
 | 
			
		||||
  const ignoredUserListEvt = useAccountData(AccountDataEvent.IgnoredUserList);
 | 
			
		||||
  const ignoredUsers = useMemo(() => {
 | 
			
		||||
    const ignoredUsersRecord =
 | 
			
		||||
      ignoredUserListEvt?.getContent<IgnoredUserListContent>().ignored_users ?? {};
 | 
			
		||||
    return Object.keys(ignoredUsersRecord);
 | 
			
		||||
  }, [ignoredUserListEvt]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
 | 
			
		||||
        <Text size="L400">Block Messages</Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Select User"
 | 
			
		||||
          description="Prevent receiving message by adding userId into blocklist."
 | 
			
		||||
        >
 | 
			
		||||
          <Box direction="Column" gap="300">
 | 
			
		||||
            <IgnoreUserInput userList={ignoredUsers} />
 | 
			
		||||
            {ignoredUsers.length > 0 && (
 | 
			
		||||
              <Box direction="Inherit" gap="100">
 | 
			
		||||
                <Text size="L400">Blocklist</Text>
 | 
			
		||||
                <Box wrap="Wrap" gap="200">
 | 
			
		||||
                  {ignoredUsers.map((userId) => (
 | 
			
		||||
                    <IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
 | 
			
		||||
                  ))}
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        </SettingTile>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										203
									
								
								src/app/features/settings/notifications/KeywordMessages.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										203
									
								
								src/app/features/settings/notifications/KeywordMessages.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,203 @@
 | 
			
		|||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
 | 
			
		||||
import { Box, Text, Badge, Button, Input, config, IconButton, Icons, Icon, Spinner } from 'folds';
 | 
			
		||||
import { useAccountData } from '../../../hooks/useAccountData';
 | 
			
		||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import {
 | 
			
		||||
  getNotificationModeActions,
 | 
			
		||||
  NotificationMode,
 | 
			
		||||
  NotificationModeOptions,
 | 
			
		||||
  useNotificationModeActions,
 | 
			
		||||
} from '../../../hooks/useNotificationMode';
 | 
			
		||||
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
 | 
			
		||||
const NOTIFY_MODE_OPS: NotificationModeOptions = {
 | 
			
		||||
  highlight: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function KeywordInput() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [keyword, setKeyword] = useState<string>('');
 | 
			
		||||
 | 
			
		||||
  const [keywordState, addKeyword] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (k: string) => {
 | 
			
		||||
        mx.addPushRule('global', PushRuleKind.ContentSpecific, k, {
 | 
			
		||||
          actions: getNotificationModeActions(NotificationMode.Notify, NOTIFY_MODE_OPS),
 | 
			
		||||
          pattern: k,
 | 
			
		||||
        });
 | 
			
		||||
        setKeyword('');
 | 
			
		||||
      },
 | 
			
		||||
      [mx]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
  const addingKeyword = keywordState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const k = evt.currentTarget.value;
 | 
			
		||||
    setKeyword(k);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setKeyword('');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (addingKeyword) return;
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const keywordInput = target?.keywordInput as HTMLInputElement | undefined;
 | 
			
		||||
    const k = keywordInput?.value.trim();
 | 
			
		||||
    if (!k) return;
 | 
			
		||||
 | 
			
		||||
    addKeyword(k);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={addingKeyword}>
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
        <Input
 | 
			
		||||
          required
 | 
			
		||||
          name="keywordInput"
 | 
			
		||||
          value={keyword}
 | 
			
		||||
          onChange={handleChange}
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          style={{ paddingRight: config.space.S200 }}
 | 
			
		||||
          readOnly={addingKeyword}
 | 
			
		||||
          after={
 | 
			
		||||
            keyword &&
 | 
			
		||||
            !addingKeyword && (
 | 
			
		||||
              <IconButton
 | 
			
		||||
                type="reset"
 | 
			
		||||
                onClick={handleReset}
 | 
			
		||||
                size="300"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
              >
 | 
			
		||||
                <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Button
 | 
			
		||||
        size="400"
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        outlined
 | 
			
		||||
        radii="300"
 | 
			
		||||
        type="submit"
 | 
			
		||||
        disabled={addingKeyword}
 | 
			
		||||
      >
 | 
			
		||||
        {addingKeyword && <Spinner variant="Secondary" size="300" />}
 | 
			
		||||
        <Text size="B400">Save</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type PushRulesProps = {
 | 
			
		||||
  pushRule: IPushRule;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function KeywordCross({ pushRule }: PushRulesProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [removeState, remove] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      () => mx.deletePushRule('global', PushRuleKind.ContentSpecific, pushRule.rule_id),
 | 
			
		||||
      [mx, pushRule]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const removing = removeState.status === AsyncStatus.Loading;
 | 
			
		||||
  return (
 | 
			
		||||
    <IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing}>
 | 
			
		||||
      {removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />}
 | 
			
		||||
    </IconButton>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function KeywordModeSwitcher({ pushRule }: PushRulesProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
 | 
			
		||||
 | 
			
		||||
  const handleChange = useCallback(
 | 
			
		||||
    async (mode: NotificationMode) => {
 | 
			
		||||
      const actions = getModeActions(mode);
 | 
			
		||||
      await mx.setPushRuleActions(
 | 
			
		||||
        'global',
 | 
			
		||||
        PushRuleKind.ContentSpecific,
 | 
			
		||||
        pushRule.rule_id,
 | 
			
		||||
        actions
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    [mx, getModeActions, pushRule]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function KeywordMessagesNotifications() {
 | 
			
		||||
  const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
 | 
			
		||||
  const pushRules = useMemo(
 | 
			
		||||
    () => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
 | 
			
		||||
    [pushRulesEvt]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const keywordPushRules = useMemo(() => {
 | 
			
		||||
    const content = pushRules.global.content ?? [];
 | 
			
		||||
    return content.filter(
 | 
			
		||||
      (pushRule) => pushRule.default === false && typeof pushRule.pattern === 'string'
 | 
			
		||||
    );
 | 
			
		||||
  }, [pushRules]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
 | 
			
		||||
        <Text size="L400">Keyword Messages</Text>
 | 
			
		||||
        <Box gap="100">
 | 
			
		||||
          <Text size="T200">Badge: </Text>
 | 
			
		||||
          <Badge radii="300" variant="Success" fill="Solid">
 | 
			
		||||
            <Text size="L400">1</Text>
 | 
			
		||||
          </Badge>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Select Keyword"
 | 
			
		||||
          description="Set a notification preference for message containing given keyword."
 | 
			
		||||
        >
 | 
			
		||||
          <KeywordInput />
 | 
			
		||||
        </SettingTile>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      {keywordPushRules.map((pushRule) => (
 | 
			
		||||
        <SequenceCard
 | 
			
		||||
          key={pushRule.rule_id}
 | 
			
		||||
          className={SequenceCardStyle}
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          direction="Column"
 | 
			
		||||
          gap="400"
 | 
			
		||||
        >
 | 
			
		||||
          <SettingTile
 | 
			
		||||
            title={`"${pushRule.pattern}"`}
 | 
			
		||||
            before={<KeywordCross pushRule={pushRule} />}
 | 
			
		||||
            after={<KeywordModeSwitcher pushRule={pushRule} />}
 | 
			
		||||
          />
 | 
			
		||||
        </SequenceCard>
 | 
			
		||||
      ))}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,117 @@
 | 
			
		|||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Menu,
 | 
			
		||||
  MenuItem,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { IPushRule } from 'matrix-js-sdk';
 | 
			
		||||
import React, { MouseEventHandler, useMemo, useState } from 'react';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
 | 
			
		||||
export const useNotificationModes = (): NotificationMode[] =>
 | 
			
		||||
  useMemo(() => [NotificationMode.NotifyLoud, NotificationMode.Notify, NotificationMode.OFF], []);
 | 
			
		||||
 | 
			
		||||
const useNotificationModeStr = (): Record<NotificationMode, string> =>
 | 
			
		||||
  useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      [NotificationMode.OFF]: 'Disable',
 | 
			
		||||
      [NotificationMode.Notify]: 'Notify Silent',
 | 
			
		||||
      [NotificationMode.NotifyLoud]: 'Notify Loud',
 | 
			
		||||
    }),
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
type NotificationModeSwitcherProps = {
 | 
			
		||||
  pushRule: IPushRule;
 | 
			
		||||
  onChange: (mode: NotificationMode) => Promise<void>;
 | 
			
		||||
};
 | 
			
		||||
export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) {
 | 
			
		||||
  const modes = useNotificationModes();
 | 
			
		||||
  const modeToStr = useNotificationModeStr();
 | 
			
		||||
  const selectedMode = useNotificationActionsMode(pushRule.actions);
 | 
			
		||||
  const [changeState, change] = useAsyncCallback(onChange);
 | 
			
		||||
  const changing = changeState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (mode: NotificationMode) => {
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
    change(mode);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Button
 | 
			
		||||
        size="300"
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        outlined
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        after={
 | 
			
		||||
          changing ? (
 | 
			
		||||
            <Spinner variant="Secondary" size="300" />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <Icon size="300" src={Icons.ChevronBottom} />
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
        onClick={handleMenu}
 | 
			
		||||
        disabled={changing}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="T300">{modeToStr[selectedMode]}</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                {modes.map((mode) => (
 | 
			
		||||
                  <MenuItem
 | 
			
		||||
                    key={mode}
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    variant="Surface"
 | 
			
		||||
                    aria-selected={mode === selectedMode}
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    onClick={() => handleSelect(mode)}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Box grow="Yes">
 | 
			
		||||
                      <Text size="T300">{modeToStr[mode]}</Text>
 | 
			
		||||
                    </Box>
 | 
			
		||||
                  </MenuItem>
 | 
			
		||||
                ))}
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Menu>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										117
									
								
								src/app/features/settings/notifications/Notifications.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/app/features/settings/notifications/Notifications.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,117 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button, color } from 'folds';
 | 
			
		||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useSetting } from '../../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../../state/settings';
 | 
			
		||||
import { usePermissionState } from '../../../hooks/usePermission';
 | 
			
		||||
import { AllMessagesNotifications } from './AllMessages';
 | 
			
		||||
import { SpecialMessagesNotifications } from './SpecialMessages';
 | 
			
		||||
import { KeywordMessagesNotifications } from './KeywordMessages';
 | 
			
		||||
import { IgnoredUserList } from './IgnoredUserList';
 | 
			
		||||
 | 
			
		||||
function SystemNotification() {
 | 
			
		||||
  const notifPermission = usePermissionState(
 | 
			
		||||
    'notifications',
 | 
			
		||||
    window.Notification.permission === 'default' ? 'prompt' : window.Notification.permission
 | 
			
		||||
  );
 | 
			
		||||
  const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
 | 
			
		||||
  const [isNotificationSounds, setIsNotificationSounds] = useSetting(
 | 
			
		||||
    settingsAtom,
 | 
			
		||||
    'isNotificationSounds'
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const requestNotificationPermission = () => {
 | 
			
		||||
    window.Notification.requestPermission();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">System</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Desktop Notifications"
 | 
			
		||||
          description={
 | 
			
		||||
            notifPermission === 'denied' ? (
 | 
			
		||||
              <Text as="span" style={{ color: color.Critical.Main }} size="T200">
 | 
			
		||||
                Notification permission is blocked. Please allow notification permission from
 | 
			
		||||
                browser address bar.
 | 
			
		||||
              </Text>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <span>Show desktop notifications when message arrive.</span>
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
          after={
 | 
			
		||||
            notifPermission === 'prompt' ? (
 | 
			
		||||
              <Button size="300" radii="300" onClick={requestNotificationPermission}>
 | 
			
		||||
                <Text size="B300">Enable</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <Switch
 | 
			
		||||
                disabled={notifPermission !== 'granted'}
 | 
			
		||||
                value={showNotifications}
 | 
			
		||||
                onChange={setShowNotifications}
 | 
			
		||||
              />
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Notification Sound"
 | 
			
		||||
          description="Play sound when new message arrive."
 | 
			
		||||
          after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type NotificationsProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function Notifications({ requestClose }: NotificationsProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false}>
 | 
			
		||||
        <Box grow="Yes" gap="200">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Text size="H3" truncate>
 | 
			
		||||
              Notifications
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <SystemNotification />
 | 
			
		||||
              <AllMessagesNotifications />
 | 
			
		||||
              <SpecialMessagesNotifications />
 | 
			
		||||
              <KeywordMessagesNotifications />
 | 
			
		||||
              <IgnoredUserList />
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										222
									
								
								src/app/features/settings/notifications/SpecialMessages.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								src/app/features/settings/notifications/SpecialMessages.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,222 @@
 | 
			
		|||
import React, { useCallback, useMemo } from 'react';
 | 
			
		||||
import { ConditionKind, IPushRules, PushRuleKind, RuleId } from 'matrix-js-sdk';
 | 
			
		||||
import { Box, Text, Badge } from 'folds';
 | 
			
		||||
import { useAccountData } from '../../../hooks/useAccountData';
 | 
			
		||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { useUserProfile } from '../../../hooks/useUserProfile';
 | 
			
		||||
import { getMxIdLocalPart } from '../../../utils/matrix';
 | 
			
		||||
import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule';
 | 
			
		||||
import {
 | 
			
		||||
  getNotificationModeActions,
 | 
			
		||||
  NotificationMode,
 | 
			
		||||
  NotificationModeOptions,
 | 
			
		||||
  useNotificationModeActions,
 | 
			
		||||
} from '../../../hooks/useNotificationMode';
 | 
			
		||||
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
 | 
			
		||||
 | 
			
		||||
const NOTIFY_MODE_OPS: NotificationModeOptions = {
 | 
			
		||||
  highlight: true,
 | 
			
		||||
};
 | 
			
		||||
const getDefaultIsUserMention = (userId: string): PushRuleData =>
 | 
			
		||||
  makePushRuleData(
 | 
			
		||||
    PushRuleKind.Override,
 | 
			
		||||
    RuleId.IsUserMention,
 | 
			
		||||
    getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
 | 
			
		||||
    [
 | 
			
		||||
      {
 | 
			
		||||
        kind: ConditionKind.EventPropertyContains,
 | 
			
		||||
        key: 'content.m\\.mentions.user_ids',
 | 
			
		||||
        value: userId,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
const DefaultContainsDisplayName = makePushRuleData(
 | 
			
		||||
  PushRuleKind.Override,
 | 
			
		||||
  RuleId.ContainsDisplayName,
 | 
			
		||||
  getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
 | 
			
		||||
  [
 | 
			
		||||
    {
 | 
			
		||||
      kind: ConditionKind.ContainsDisplayName,
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const getDefaultContainsUsername = (username: string) =>
 | 
			
		||||
  makePushRuleData(
 | 
			
		||||
    PushRuleKind.ContentSpecific,
 | 
			
		||||
    RuleId.ContainsUserName,
 | 
			
		||||
    getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
 | 
			
		||||
    undefined,
 | 
			
		||||
    username
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
const DefaultIsRoomMention = makePushRuleData(
 | 
			
		||||
  PushRuleKind.Override,
 | 
			
		||||
  RuleId.IsRoomMention,
 | 
			
		||||
  getNotificationModeActions(NotificationMode.Notify, { highlight: true }),
 | 
			
		||||
  [
 | 
			
		||||
    {
 | 
			
		||||
      kind: ConditionKind.EventPropertyIs,
 | 
			
		||||
      key: 'content.m\\.mentions.room',
 | 
			
		||||
      value: true,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      kind: ConditionKind.SenderNotificationPermission,
 | 
			
		||||
      key: 'room',
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const DefaultAtRoomNotification = makePushRuleData(
 | 
			
		||||
  PushRuleKind.Override,
 | 
			
		||||
  RuleId.AtRoomNotification,
 | 
			
		||||
  getNotificationModeActions(NotificationMode.Notify, { highlight: true }),
 | 
			
		||||
  [
 | 
			
		||||
    {
 | 
			
		||||
      kind: ConditionKind.EventMatch,
 | 
			
		||||
      key: 'content.body',
 | 
			
		||||
      pattern: '@room',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      kind: ConditionKind.SenderNotificationPermission,
 | 
			
		||||
      key: 'room',
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type PushRulesProps = {
 | 
			
		||||
  ruleId: RuleId;
 | 
			
		||||
  pushRules: IPushRules;
 | 
			
		||||
  defaultPushRuleData: PushRuleData;
 | 
			
		||||
};
 | 
			
		||||
function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRulesProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
 | 
			
		||||
  const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
 | 
			
		||||
 | 
			
		||||
  const handleChange = useCallback(
 | 
			
		||||
    async (mode: NotificationMode) => {
 | 
			
		||||
      const actions = getModeActions(mode);
 | 
			
		||||
      await mx.setPushRuleActions('global', kind, ruleId, actions);
 | 
			
		||||
    },
 | 
			
		||||
    [mx, getModeActions, kind, ruleId]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function SpecialMessagesNotifications() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
  const { displayName } = useUserProfile(userId);
 | 
			
		||||
  const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
 | 
			
		||||
  const pushRules = useMemo(
 | 
			
		||||
    () => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
 | 
			
		||||
    [pushRulesEvt]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
 | 
			
		||||
        <Text size="L400">Special Messages</Text>
 | 
			
		||||
        <Box gap="100">
 | 
			
		||||
          <Text size="T200">Badge: </Text>
 | 
			
		||||
          <Badge radii="300" variant="Success" fill="Solid">
 | 
			
		||||
            <Text size="L400">1</Text>
 | 
			
		||||
          </Badge>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title={`Mention User ID ("${userId}")`}
 | 
			
		||||
          after={
 | 
			
		||||
            <MentionModeSwitcher
 | 
			
		||||
              pushRules={pushRules}
 | 
			
		||||
              ruleId={RuleId.IsUserMention}
 | 
			
		||||
              defaultPushRuleData={getDefaultIsUserMention(userId)}
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title={`Contains Displayname ${displayName ? `("${displayName}")` : ''}`}
 | 
			
		||||
          after={
 | 
			
		||||
            <MentionModeSwitcher
 | 
			
		||||
              pushRules={pushRules}
 | 
			
		||||
              ruleId={RuleId.ContainsDisplayName}
 | 
			
		||||
              defaultPushRuleData={DefaultContainsDisplayName}
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title={`Contains Username ("${getMxIdLocalPart(userId)}")`}
 | 
			
		||||
          after={
 | 
			
		||||
            <MentionModeSwitcher
 | 
			
		||||
              pushRules={pushRules}
 | 
			
		||||
              ruleId={RuleId.ContainsUserName}
 | 
			
		||||
              defaultPushRuleData={getDefaultContainsUsername(getMxIdLocalPart(userId) ?? userId)}
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Mention @room"
 | 
			
		||||
          after={
 | 
			
		||||
            <MentionModeSwitcher
 | 
			
		||||
              pushRules={pushRules}
 | 
			
		||||
              ruleId={RuleId.IsRoomMention}
 | 
			
		||||
              defaultPushRuleData={DefaultIsRoomMention}
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Contains @room"
 | 
			
		||||
          after={
 | 
			
		||||
            <MentionModeSwitcher
 | 
			
		||||
              pushRules={pushRules}
 | 
			
		||||
              ruleId={RuleId.AtRoomNotification}
 | 
			
		||||
              defaultPushRuleData={DefaultAtRoomNotification}
 | 
			
		||||
            />
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/settings/notifications/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/settings/notifications/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './Notifications';
 | 
			
		||||
							
								
								
									
										6
									
								
								src/app/features/settings/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/app/features/settings/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const SequenceCardStyle = style({
 | 
			
		||||
  padding: config.space.S300,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -1,21 +0,0 @@
 | 
			
		|||
/* eslint-disable import/prefer-default-export */
 | 
			
		||||
import { useState, useEffect } from 'react';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
 | 
			
		||||
export function useAccountData(eventType) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [event, setEvent] = useState(mx.getAccountData(eventType));
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleChange = (mEvent) => {
 | 
			
		||||
      if (mEvent.getType() !== eventType) return;
 | 
			
		||||
      setEvent(mEvent);
 | 
			
		||||
    };
 | 
			
		||||
    mx.on('accountData', handleChange);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener('accountData', handleChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, eventType]);
 | 
			
		||||
 | 
			
		||||
  return event;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								src/app/hooks/useAccountData.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/app/hooks/useAccountData.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
import { useState, useCallback } from 'react';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
import { useAccountDataCallback } from './useAccountDataCallback';
 | 
			
		||||
 | 
			
		||||
export function useAccountData(eventType: string) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [event, setEvent] = useState(() => mx.getAccountData(eventType));
 | 
			
		||||
 | 
			
		||||
  useAccountDataCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (evt) => {
 | 
			
		||||
        if (evt.getType() === eventType) {
 | 
			
		||||
          setEvent(evt);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [eventType, setEvent]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return event;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { useCallback, useRef, useState } from 'react';
 | 
			
		||||
import { useCallback, useEffect, useRef, useState } from 'react';
 | 
			
		||||
import { flushSync } from 'react-dom';
 | 
			
		||||
import { useAlive } from './useAlive';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -31,12 +31,10 @@ export type AsyncState<D, E = unknown> = AsyncIdle | AsyncLoading | AsyncSuccess
 | 
			
		|||
 | 
			
		||||
export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
 | 
			
		||||
 | 
			
		||||
export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
			
		||||
  asyncCallback: AsyncCallback<TArgs, TData>
 | 
			
		||||
): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
 | 
			
		||||
  const [state, setState] = useState<AsyncState<TData, TError>>({
 | 
			
		||||
    status: AsyncStatus.Idle,
 | 
			
		||||
  });
 | 
			
		||||
export const useAsync = <TData, TError, TArgs extends unknown[]>(
 | 
			
		||||
  asyncCallback: AsyncCallback<TArgs, TData>,
 | 
			
		||||
  onStateChange: (state: AsyncState<TData, TError>) => void
 | 
			
		||||
): AsyncCallback<TArgs, TData> => {
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  // Tracks the request number.
 | 
			
		||||
| 
						 | 
				
			
			@ -53,7 +51,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
			
		|||
        flushSync(() => {
 | 
			
		||||
          // flushSync because
 | 
			
		||||
          // https://github.com/facebook/react/issues/26713#issuecomment-1872085134
 | 
			
		||||
          setState({
 | 
			
		||||
          onStateChange({
 | 
			
		||||
            status: AsyncStatus.Loading,
 | 
			
		||||
          });
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -69,7 +67,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
			
		|||
        }
 | 
			
		||||
        if (alive()) {
 | 
			
		||||
          queueMicrotask(() => {
 | 
			
		||||
            setState({
 | 
			
		||||
            onStateChange({
 | 
			
		||||
              status: AsyncStatus.Success,
 | 
			
		||||
              data,
 | 
			
		||||
            });
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +81,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
			
		|||
 | 
			
		||||
        if (alive()) {
 | 
			
		||||
          queueMicrotask(() => {
 | 
			
		||||
            setState({
 | 
			
		||||
            onStateChange({
 | 
			
		||||
              status: AsyncStatus.Error,
 | 
			
		||||
              error: e as TError,
 | 
			
		||||
            });
 | 
			
		||||
| 
						 | 
				
			
			@ -92,8 +90,32 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
			
		|||
        throw e;
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [asyncCallback, alive]
 | 
			
		||||
    [asyncCallback, alive, onStateChange]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return callback;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
			
		||||
  asyncCallback: AsyncCallback<TArgs, TData>
 | 
			
		||||
): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
 | 
			
		||||
  const [state, setState] = useState<AsyncState<TData, TError>>({
 | 
			
		||||
    status: AsyncStatus.Idle,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const callback = useAsync(asyncCallback, setState);
 | 
			
		||||
 | 
			
		||||
  return [state, callback];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useAsyncCallbackValue = <TData, TError>(
 | 
			
		||||
  asyncCallback: AsyncCallback<[], TData>
 | 
			
		||||
): [AsyncState<TData, TError>, AsyncCallback<[], TData>] => {
 | 
			
		||||
  const [state, load] = useAsyncCallback<TData, TError, []>(asyncCallback);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    load();
 | 
			
		||||
  }, [load]);
 | 
			
		||||
 | 
			
		||||
  return [state, load];
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										9
									
								
								src/app/hooks/useCrossSigning.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/app/hooks/useCrossSigning.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import { AccountDataEvent, SecretAccountData } from '../../types/matrix/accountData';
 | 
			
		||||
import { useAccountData } from './useAccountData';
 | 
			
		||||
 | 
			
		||||
export const useCrossSigningActive = (): boolean => {
 | 
			
		||||
  const masterEvent = useAccountData(AccountDataEvent.CrossSigningMaster);
 | 
			
		||||
  const content = masterEvent?.getContent<SecretAccountData>();
 | 
			
		||||
 | 
			
		||||
  return !!content;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,25 +0,0 @@
 | 
			
		|||
/* eslint-disable import/prefer-default-export */
 | 
			
		||||
import { useState, useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
import { hasCrossSigningAccountData } from '../../util/matrixUtil';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
 | 
			
		||||
export function useCrossSigningStatus() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData(mx));
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (isCSEnabled) return undefined;
 | 
			
		||||
    const handleAccountData = (event) => {
 | 
			
		||||
      if (event.getType() === 'm.cross_signing.master') {
 | 
			
		||||
        setIsCSEnabled(true);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    mx.on('accountData', handleAccountData);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener('accountData', handleAccountData);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, isCSEnabled]);
 | 
			
		||||
  return isCSEnabled;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,35 +1,77 @@
 | 
			
		|||
/* eslint-disable import/prefer-default-export */
 | 
			
		||||
import { useState, useEffect } from 'react';
 | 
			
		||||
import { CryptoEvent, IMyDevice } from 'matrix-js-sdk';
 | 
			
		||||
import { CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
 | 
			
		||||
import { useEffect, useCallback, useMemo } from 'react';
 | 
			
		||||
import { IMyDevice } from 'matrix-js-sdk';
 | 
			
		||||
import { useQuery } from '@tanstack/react-query';
 | 
			
		||||
import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
 | 
			
		||||
export function useDeviceList() {
 | 
			
		||||
export const useDeviceListChange = (
 | 
			
		||||
  onChange: CryptoEventHandlerMap[CryptoEvent.DevicesUpdated]
 | 
			
		||||
) => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [deviceList, setDeviceList] = useState<IMyDevice[] | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    let isMounted = true;
 | 
			
		||||
    mx.on(CryptoEvent.DevicesUpdated, onChange);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener(CryptoEvent.DevicesUpdated, onChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, onChange]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
    const updateDevices = () =>
 | 
			
		||||
      mx.getDevices().then((data) => {
 | 
			
		||||
        if (!isMounted) return;
 | 
			
		||||
        setDeviceList(data.devices || []);
 | 
			
		||||
const DEVICES_QUERY_KEY = ['devices'];
 | 
			
		||||
 | 
			
		||||
export function useDeviceList(): [undefined | IMyDevice[], () => Promise<void>] {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const fetchDevices = useCallback(async () => {
 | 
			
		||||
    const data = await mx.getDevices();
 | 
			
		||||
    return data.devices ?? [];
 | 
			
		||||
  }, [mx]);
 | 
			
		||||
 | 
			
		||||
  const { data: deviceList, refetch } = useQuery({
 | 
			
		||||
    queryKey: DEVICES_QUERY_KEY,
 | 
			
		||||
    queryFn: fetchDevices,
 | 
			
		||||
    staleTime: 0,
 | 
			
		||||
    gcTime: Infinity,
 | 
			
		||||
    refetchOnMount: 'always',
 | 
			
		||||
  });
 | 
			
		||||
    updateDevices();
 | 
			
		||||
 | 
			
		||||
    const handleDevicesUpdate: CryptoEventHandlerMap[CryptoEvent.DevicesUpdated] = (users) => {
 | 
			
		||||
  const refreshDeviceList = useCallback(async () => {
 | 
			
		||||
    await refetch();
 | 
			
		||||
  }, [refetch]);
 | 
			
		||||
 | 
			
		||||
  useDeviceListChange(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (users) => {
 | 
			
		||||
        const userId = mx.getUserId();
 | 
			
		||||
        if (userId && users.includes(userId)) {
 | 
			
		||||
        updateDevices();
 | 
			
		||||
          refreshDeviceList();
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
      },
 | 
			
		||||
      [mx, refreshDeviceList]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
    mx.on(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
 | 
			
		||||
      isMounted = false;
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx]);
 | 
			
		||||
  return deviceList;
 | 
			
		||||
  return [deviceList ?? undefined, refreshDeviceList];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useDeviceIds = (devices: IMyDevice[] | undefined): string[] => {
 | 
			
		||||
  const devicesId = useMemo(() => devices?.map((device) => device.device_id) ?? [], [devices]);
 | 
			
		||||
 | 
			
		||||
  return devicesId;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useSplitCurrentDevice = (
 | 
			
		||||
  devices: IMyDevice[] | undefined
 | 
			
		||||
): [IMyDevice | undefined, IMyDevice[] | undefined] => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const currentDeviceId = mx.getDeviceId();
 | 
			
		||||
  const currentDevice = useMemo(
 | 
			
		||||
    () => devices?.find((d) => d.device_id === currentDeviceId),
 | 
			
		||||
    [devices, currentDeviceId]
 | 
			
		||||
  );
 | 
			
		||||
  const otherDevices = useMemo(
 | 
			
		||||
    () => devices?.filter((device) => device.device_id !== currentDeviceId),
 | 
			
		||||
    [devices, currentDeviceId]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return [currentDevice, otherDevices];
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										106
									
								
								src/app/hooks/useDeviceVerificationStatus.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/app/hooks/useDeviceVerificationStatus.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,106 @@
 | 
			
		|||
import { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import { verifiedDevice } from '../utils/matrix-crypto';
 | 
			
		||||
import { useAlive } from './useAlive';
 | 
			
		||||
import { fulfilledPromiseSettledResult } from '../utils/common';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
import { useDeviceListChange } from './useDeviceList';
 | 
			
		||||
 | 
			
		||||
export enum VerificationStatus {
 | 
			
		||||
  Unknown,
 | 
			
		||||
  Unverified,
 | 
			
		||||
  Verified,
 | 
			
		||||
  Unsupported,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useDeviceVerificationDetect = (
 | 
			
		||||
  crypto: CryptoApi | undefined,
 | 
			
		||||
  userId: string,
 | 
			
		||||
  deviceId: string | undefined,
 | 
			
		||||
  callback: (status: VerificationStatus) => void
 | 
			
		||||
): void => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const updateStatus = useCallback(async () => {
 | 
			
		||||
    if (crypto && deviceId) {
 | 
			
		||||
      const data = await verifiedDevice(crypto, userId, deviceId);
 | 
			
		||||
      if (data === null) {
 | 
			
		||||
        callback(VerificationStatus.Unsupported);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      callback(data ? VerificationStatus.Verified : VerificationStatus.Unverified);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    callback(VerificationStatus.Unknown);
 | 
			
		||||
  }, [crypto, deviceId, userId, callback]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    updateStatus();
 | 
			
		||||
  }, [mx, updateStatus, userId]);
 | 
			
		||||
 | 
			
		||||
  useDeviceListChange(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (userIds) => {
 | 
			
		||||
        if (userIds.includes(userId)) {
 | 
			
		||||
          updateStatus();
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [userId, updateStatus]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useDeviceVerificationStatus = (
 | 
			
		||||
  crypto: CryptoApi | undefined,
 | 
			
		||||
  userId: string,
 | 
			
		||||
  deviceId: string | undefined
 | 
			
		||||
): VerificationStatus => {
 | 
			
		||||
  const [verificationStatus, setVerificationStatus] = useState(VerificationStatus.Unknown);
 | 
			
		||||
 | 
			
		||||
  useDeviceVerificationDetect(crypto, userId, deviceId, setVerificationStatus);
 | 
			
		||||
 | 
			
		||||
  return verificationStatus;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useUnverifiedDeviceCount = (
 | 
			
		||||
  crypto: CryptoApi | undefined,
 | 
			
		||||
  userId: string,
 | 
			
		||||
  devices: string[]
 | 
			
		||||
): number | undefined => {
 | 
			
		||||
  const [unverifiedCount, setUnverifiedCount] = useState<number>();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const updateCount = useCallback(async () => {
 | 
			
		||||
    let count = 0;
 | 
			
		||||
    if (crypto) {
 | 
			
		||||
      const promises = devices.map((deviceId) => verifiedDevice(crypto, userId, deviceId));
 | 
			
		||||
      const result = await Promise.allSettled(promises);
 | 
			
		||||
      const settledResult = fulfilledPromiseSettledResult(result);
 | 
			
		||||
      settledResult.forEach((status) => {
 | 
			
		||||
        if (status === false) {
 | 
			
		||||
          count += 1;
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    if (alive()) {
 | 
			
		||||
      setUnverifiedCount(count);
 | 
			
		||||
    }
 | 
			
		||||
  }, [crypto, userId, devices, alive]);
 | 
			
		||||
 | 
			
		||||
  useDeviceListChange(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (userIds) => {
 | 
			
		||||
        if (userIds.includes(userId)) {
 | 
			
		||||
          updateCount();
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [userId, updateCount]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    updateCount();
 | 
			
		||||
  }, [updateCount]);
 | 
			
		||||
 | 
			
		||||
  return unverifiedCount;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,48 +1,161 @@
 | 
			
		|||
import { ClientEvent, MatrixClient, MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
 | 
			
		||||
import { useEffect, useMemo } from 'react';
 | 
			
		||||
import { getRelevantPacks, ImagePack, PackUsage } from '../plugins/custom-emoji';
 | 
			
		||||
import { Room } from 'matrix-js-sdk';
 | 
			
		||||
import { useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import { AccountDataEvent } from '../../types/matrix/accountData';
 | 
			
		||||
import { StateEvent } from '../../types/matrix/room';
 | 
			
		||||
import { useForceUpdate } from './useForceUpdate';
 | 
			
		||||
import {
 | 
			
		||||
  getGlobalImagePacks,
 | 
			
		||||
  getRoomImagePack,
 | 
			
		||||
  getRoomImagePacks,
 | 
			
		||||
  getUserImagePack,
 | 
			
		||||
  ImagePack,
 | 
			
		||||
  ImageUsage,
 | 
			
		||||
} from '../plugins/custom-emoji';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
import { useAccountDataCallback } from './useAccountDataCallback';
 | 
			
		||||
import { useStateEventCallback } from './useStateEventCallback';
 | 
			
		||||
 | 
			
		||||
export const useRelevantImagePacks = (
 | 
			
		||||
  mx: MatrixClient,
 | 
			
		||||
  usage: PackUsage,
 | 
			
		||||
  rooms: Room[]
 | 
			
		||||
): ImagePack[] => {
 | 
			
		||||
  const [forceCount, forceUpdate] = useForceUpdate();
 | 
			
		||||
export const useUserImagePack = (): ImagePack | undefined => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [userPack, setUserPack] = useState(() => getUserImagePack(mx));
 | 
			
		||||
 | 
			
		||||
  const relevantPacks = useMemo(
 | 
			
		||||
    () => getRelevantPacks(mx, rooms).filter((pack) => pack.getImagesFor(usage).length > 0),
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
    [mx, usage, rooms, forceCount]
 | 
			
		||||
  useAccountDataCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (mEvent) => {
 | 
			
		||||
        if (mEvent.getType() === AccountDataEvent.PoniesUserEmotes) {
 | 
			
		||||
          setUserPack(getUserImagePack(mx));
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [mx]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleUpdate = (event: MatrixEvent) => {
 | 
			
		||||
      if (
 | 
			
		||||
        event.getType() === AccountDataEvent.PoniesEmoteRooms ||
 | 
			
		||||
        event.getType() === AccountDataEvent.PoniesUserEmotes
 | 
			
		||||
      ) {
 | 
			
		||||
        forceUpdate();
 | 
			
		||||
      }
 | 
			
		||||
      const eventRoomId = event.getRoomId();
 | 
			
		||||
      if (
 | 
			
		||||
        eventRoomId &&
 | 
			
		||||
        event.getType() === StateEvent.PoniesRoomEmotes &&
 | 
			
		||||
        rooms.find((room) => room.roomId === eventRoomId)
 | 
			
		||||
      ) {
 | 
			
		||||
        forceUpdate();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  return userPack;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
    mx.on(ClientEvent.AccountData, handleUpdate);
 | 
			
		||||
    mx.on(RoomStateEvent.Events, handleUpdate);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener(ClientEvent.AccountData, handleUpdate);
 | 
			
		||||
      mx.removeListener(RoomStateEvent.Events, handleUpdate);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, rooms, forceUpdate]);
 | 
			
		||||
export const useGlobalImagePacks = (): ImagePack[] => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [globalPacks, setGlobalPacks] = useState(() => getGlobalImagePacks(mx));
 | 
			
		||||
 | 
			
		||||
  useAccountDataCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (mEvent) => {
 | 
			
		||||
        if (mEvent.getType() === AccountDataEvent.PoniesEmoteRooms) {
 | 
			
		||||
          setGlobalPacks(getGlobalImagePacks(mx));
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [mx]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useStateEventCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (mEvent) => {
 | 
			
		||||
        const eventType = mEvent.getType();
 | 
			
		||||
        const roomId = mEvent.getRoomId();
 | 
			
		||||
        const stateKey = mEvent.getStateKey();
 | 
			
		||||
        if (eventType === StateEvent.PoniesRoomEmotes && roomId && typeof stateKey === 'string') {
 | 
			
		||||
          const global = !!globalPacks.find(
 | 
			
		||||
            (pack) =>
 | 
			
		||||
              pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey
 | 
			
		||||
          );
 | 
			
		||||
          if (global) {
 | 
			
		||||
            setGlobalPacks(getGlobalImagePacks(mx));
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [mx, globalPacks]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return globalPacks;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useRoomImagePack = (room: Room, stateKey: string): ImagePack | undefined => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [roomPack, setRoomPack] = useState(() => getRoomImagePack(room, stateKey));
 | 
			
		||||
 | 
			
		||||
  useStateEventCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (mEvent) => {
 | 
			
		||||
        if (
 | 
			
		||||
          mEvent.getRoomId() === room.roomId &&
 | 
			
		||||
          mEvent.getType() === StateEvent.PoniesRoomEmotes &&
 | 
			
		||||
          mEvent.getStateKey() === stateKey
 | 
			
		||||
        ) {
 | 
			
		||||
          setRoomPack(getRoomImagePack(room, stateKey));
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [room, stateKey]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return roomPack;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useRoomImagePacks = (room: Room): ImagePack[] => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [roomPacks, setRoomPacks] = useState(() => getRoomImagePacks(room));
 | 
			
		||||
 | 
			
		||||
  useStateEventCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (mEvent) => {
 | 
			
		||||
        if (
 | 
			
		||||
          mEvent.getRoomId() === room.roomId &&
 | 
			
		||||
          mEvent.getType() === StateEvent.PoniesRoomEmotes
 | 
			
		||||
        ) {
 | 
			
		||||
          setRoomPacks(getRoomImagePacks(room));
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [room]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return roomPacks;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useRoomsImagePacks = (rooms: Room[]) => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomImagePacks));
 | 
			
		||||
 | 
			
		||||
  useStateEventCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (mEvent) => {
 | 
			
		||||
        if (
 | 
			
		||||
          rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
 | 
			
		||||
          mEvent.getType() === StateEvent.PoniesRoomEmotes
 | 
			
		||||
        ) {
 | 
			
		||||
          setRoomPacks(rooms.flatMap(getRoomImagePacks));
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [rooms]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return roomPacks;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useRelevantImagePacks = (usage: ImageUsage, rooms: Room[]): ImagePack[] => {
 | 
			
		||||
  const userPack = useUserImagePack();
 | 
			
		||||
  const globalPacks = useGlobalImagePacks();
 | 
			
		||||
  const roomsPacks = useRoomsImagePacks(rooms);
 | 
			
		||||
 | 
			
		||||
  const relevantPacks = useMemo(() => {
 | 
			
		||||
    const packs = userPack ? [userPack] : [];
 | 
			
		||||
    const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
 | 
			
		||||
 | 
			
		||||
    const relPacks = packs.concat(
 | 
			
		||||
      globalPacks,
 | 
			
		||||
      roomsPacks.filter((pack) => !globalPackIds.has(pack.id))
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return relPacks.filter((pack) => pack.getImages(usage).length > 0);
 | 
			
		||||
  }, [userPack, globalPacks, roomsPacks, usage]);
 | 
			
		||||
 | 
			
		||||
  return relevantPacks;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										160
									
								
								src/app/hooks/useKeyBackup.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								src/app/hooks/useKeyBackup.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,160 @@
 | 
			
		|||
import {
 | 
			
		||||
  BackupTrustInfo,
 | 
			
		||||
  CryptoApi,
 | 
			
		||||
  CryptoEvent,
 | 
			
		||||
  CryptoEventHandlerMap,
 | 
			
		||||
  KeyBackupInfo,
 | 
			
		||||
} from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
import { useAlive } from './useAlive';
 | 
			
		||||
 | 
			
		||||
export const useKeyBackupStatusChange = (
 | 
			
		||||
  onChange: CryptoEventHandlerMap[CryptoEvent.KeyBackupStatus]
 | 
			
		||||
) => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    mx.on(CryptoEvent.KeyBackupStatus, onChange);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener(CryptoEvent.KeyBackupStatus, onChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, onChange]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useKeyBackupStatus = (crypto: CryptoApi): boolean => {
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
  const [status, setStatus] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    crypto.getActiveSessionBackupVersion().then((v) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        setStatus(typeof v === 'string');
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }, [crypto, alive]);
 | 
			
		||||
 | 
			
		||||
  useKeyBackupStatusChange(setStatus);
 | 
			
		||||
 | 
			
		||||
  return status;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useKeyBackupSessionsRemainingChange = (
 | 
			
		||||
  onChange: CryptoEventHandlerMap[CryptoEvent.KeyBackupSessionsRemaining]
 | 
			
		||||
) => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    mx.on(CryptoEvent.KeyBackupSessionsRemaining, onChange);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener(CryptoEvent.KeyBackupSessionsRemaining, onChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, onChange]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useKeyBackupFailedChange = (
 | 
			
		||||
  onChange: CryptoEventHandlerMap[CryptoEvent.KeyBackupFailed]
 | 
			
		||||
) => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    mx.on(CryptoEvent.KeyBackupFailed, onChange);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener(CryptoEvent.KeyBackupFailed, onChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, onChange]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useKeyBackupDecryptionKeyCached = (
 | 
			
		||||
  onChange: CryptoEventHandlerMap[CryptoEvent.KeyBackupDecryptionKeyCached]
 | 
			
		||||
) => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    mx.on(CryptoEvent.KeyBackupDecryptionKeyCached, onChange);
 | 
			
		||||
    return () => {
 | 
			
		||||
      mx.removeListener(CryptoEvent.KeyBackupDecryptionKeyCached, onChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mx, onChange]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useKeyBackupSync = (): [number, string | undefined] => {
 | 
			
		||||
  const [remaining, setRemaining] = useState(0);
 | 
			
		||||
  const [failure, setFailure] = useState<string>();
 | 
			
		||||
 | 
			
		||||
  useKeyBackupSessionsRemainingChange(
 | 
			
		||||
    useCallback((count) => {
 | 
			
		||||
      setRemaining(count);
 | 
			
		||||
      setFailure(undefined);
 | 
			
		||||
    }, [])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useKeyBackupFailedChange(
 | 
			
		||||
    useCallback((f) => {
 | 
			
		||||
      if (typeof f === 'string') {
 | 
			
		||||
        setFailure(f);
 | 
			
		||||
        setRemaining(0);
 | 
			
		||||
      }
 | 
			
		||||
    }, [])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return [remaining, failure];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useKeyBackupInfo = (crypto: CryptoApi): KeyBackupInfo | undefined | null => {
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
  const [info, setInfo] = useState<KeyBackupInfo | null>();
 | 
			
		||||
 | 
			
		||||
  const fetchInfo = useCallback(() => {
 | 
			
		||||
    crypto.getKeyBackupInfo().then((i) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        setInfo(i);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }, [crypto, alive]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    fetchInfo();
 | 
			
		||||
  }, [fetchInfo]);
 | 
			
		||||
 | 
			
		||||
  useKeyBackupStatusChange(fetchInfo);
 | 
			
		||||
 | 
			
		||||
  useKeyBackupSessionsRemainingChange(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      (remainingCount) => {
 | 
			
		||||
        if (remainingCount === 0) {
 | 
			
		||||
          fetchInfo();
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [fetchInfo]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return info;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useKeyBackupTrust = (
 | 
			
		||||
  crypto: CryptoApi,
 | 
			
		||||
  backupInfo: KeyBackupInfo
 | 
			
		||||
): BackupTrustInfo | undefined => {
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
  const [trust, setTrust] = useState<BackupTrustInfo>();
 | 
			
		||||
 | 
			
		||||
  const fetchTrust = useCallback(() => {
 | 
			
		||||
    crypto.isKeyBackupTrusted(backupInfo).then((t) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        setTrust(t);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }, [crypto, alive, backupInfo]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    fetchTrust();
 | 
			
		||||
  }, [fetchTrust]);
 | 
			
		||||
 | 
			
		||||
  useKeyBackupStatusChange(fetchTrust);
 | 
			
		||||
 | 
			
		||||
  useKeyBackupDecryptionKeyCached(fetchTrust);
 | 
			
		||||
 | 
			
		||||
  return trust;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										26
									
								
								src/app/hooks/useMessageLayout.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/app/hooks/useMessageLayout.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
import { useMemo } from 'react';
 | 
			
		||||
import { MessageLayout } from '../state/settings';
 | 
			
		||||
 | 
			
		||||
export type MessageLayoutItem = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  layout: MessageLayout;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useMessageLayoutItems = (): MessageLayoutItem[] =>
 | 
			
		||||
  useMemo(
 | 
			
		||||
    () => [
 | 
			
		||||
      {
 | 
			
		||||
        layout: MessageLayout.Modern,
 | 
			
		||||
        name: 'Modern',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        layout: MessageLayout.Compact,
 | 
			
		||||
        name: 'Compact',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        layout: MessageLayout.Bubble,
 | 
			
		||||
        name: 'Bubble',
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
							
								
								
									
										38
									
								
								src/app/hooks/useMessageSpacing.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/app/hooks/useMessageSpacing.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
import { useMemo } from 'react';
 | 
			
		||||
import { MessageSpacing } from '../state/settings';
 | 
			
		||||
 | 
			
		||||
export type MessageSpacingItem = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  spacing: MessageSpacing;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useMessageSpacingItems = (): MessageSpacingItem[] =>
 | 
			
		||||
  useMemo(
 | 
			
		||||
    () => [
 | 
			
		||||
      {
 | 
			
		||||
        spacing: '0',
 | 
			
		||||
        name: 'None',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        spacing: '100',
 | 
			
		||||
        name: 'Ultra Small',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        spacing: '200',
 | 
			
		||||
        name: 'Extra Small',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        spacing: '300',
 | 
			
		||||
        name: 'Small',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        spacing: '400',
 | 
			
		||||
        name: 'Normal',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        spacing: '500',
 | 
			
		||||
        name: 'Large',
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
							
								
								
									
										66
									
								
								src/app/hooks/useNotificationMode.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/app/hooks/useNotificationMode.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,66 @@
 | 
			
		|||
import { PushRuleAction, PushRuleActionName, TweakName } from 'matrix-js-sdk';
 | 
			
		||||
import { useCallback, useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
export enum NotificationMode {
 | 
			
		||||
  OFF = 'OFF',
 | 
			
		||||
  Notify = 'Notify',
 | 
			
		||||
  NotifyLoud = 'NotifyLoud',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type NotificationModeOptions = {
 | 
			
		||||
  soundValue?: string;
 | 
			
		||||
  highlight?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export const getNotificationModeActions = (
 | 
			
		||||
  mode: NotificationMode,
 | 
			
		||||
  options?: NotificationModeOptions
 | 
			
		||||
): PushRuleAction[] => {
 | 
			
		||||
  if (mode === NotificationMode.OFF) return [];
 | 
			
		||||
 | 
			
		||||
  const actions: PushRuleAction[] = [PushRuleActionName.Notify];
 | 
			
		||||
 | 
			
		||||
  if (mode === NotificationMode.NotifyLoud) {
 | 
			
		||||
    actions.push({
 | 
			
		||||
      set_tweak: TweakName.Sound,
 | 
			
		||||
      value: options?.soundValue ?? 'default',
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (options?.highlight) {
 | 
			
		||||
    actions.push({
 | 
			
		||||
      set_tweak: TweakName.Highlight,
 | 
			
		||||
      value: true,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return actions;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
 | 
			
		||||
export const useNotificationModeActions = (
 | 
			
		||||
  options?: NotificationModeOptions
 | 
			
		||||
): GetNotificationModeCallback => {
 | 
			
		||||
  const getAction: GetNotificationModeCallback = useCallback(
 | 
			
		||||
    (mode) => getNotificationModeActions(mode, options),
 | 
			
		||||
    [options]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return getAction;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useNotificationActionsMode = (actions: PushRuleAction[]): NotificationMode => {
 | 
			
		||||
  const mode: NotificationMode = useMemo(() => {
 | 
			
		||||
    const soundTweak = actions.find(
 | 
			
		||||
      (action) => typeof action === 'object' && action.set_tweak === TweakName.Sound
 | 
			
		||||
    );
 | 
			
		||||
    const notify = actions.find(
 | 
			
		||||
      (action) => typeof action === 'string' && action === PushRuleActionName.Notify
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (notify && soundTweak) return NotificationMode.NotifyLoud;
 | 
			
		||||
    if (notify) return NotificationMode.Notify;
 | 
			
		||||
    return NotificationMode.OFF;
 | 
			
		||||
  }, [actions]);
 | 
			
		||||
 | 
			
		||||
  return mode;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										17
									
								
								src/app/hooks/useObjectURL.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/app/hooks/useObjectURL.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
import { useEffect, useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
export const useObjectURL = (object?: Blob): string | undefined => {
 | 
			
		||||
  const url = useMemo(() => {
 | 
			
		||||
    if (object) return URL.createObjectURL(object);
 | 
			
		||||
    return undefined;
 | 
			
		||||
  }, [object]);
 | 
			
		||||
 | 
			
		||||
  useEffect(
 | 
			
		||||
    () => () => {
 | 
			
		||||
      if (url) URL.revokeObjectURL(url);
 | 
			
		||||
    },
 | 
			
		||||
    [url]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return url;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										71
									
								
								src/app/hooks/usePushRule.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/app/hooks/usePushRule.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
import {
 | 
			
		||||
  IPushRule,
 | 
			
		||||
  IPushRules,
 | 
			
		||||
  PushRuleAction,
 | 
			
		||||
  PushRuleCondition,
 | 
			
		||||
  PushRuleKind,
 | 
			
		||||
  RuleId,
 | 
			
		||||
} from 'matrix-js-sdk';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
export type PushRuleData = {
 | 
			
		||||
  kind: PushRuleKind;
 | 
			
		||||
  pushRule: IPushRule;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const makePushRuleData = (
 | 
			
		||||
  kind: PushRuleKind,
 | 
			
		||||
  ruleId: RuleId,
 | 
			
		||||
  actions: PushRuleAction[],
 | 
			
		||||
  conditions?: PushRuleCondition[],
 | 
			
		||||
  pattern?: string,
 | 
			
		||||
  enabled?: boolean,
 | 
			
		||||
  _default?: boolean
 | 
			
		||||
): PushRuleData => ({
 | 
			
		||||
  kind,
 | 
			
		||||
  pushRule: {
 | 
			
		||||
    rule_id: ruleId,
 | 
			
		||||
    default: _default ?? true,
 | 
			
		||||
    enabled: enabled ?? true,
 | 
			
		||||
    pattern,
 | 
			
		||||
    conditions,
 | 
			
		||||
    actions,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const orderedPushRuleKinds: PushRuleKind[] = [
 | 
			
		||||
  PushRuleKind.Override,
 | 
			
		||||
  PushRuleKind.ContentSpecific,
 | 
			
		||||
  PushRuleKind.RoomSpecific,
 | 
			
		||||
  PushRuleKind.SenderSpecific,
 | 
			
		||||
  PushRuleKind.Underride,
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export const getPushRule = (
 | 
			
		||||
  pushRules: IPushRules,
 | 
			
		||||
  ruleId: RuleId | string
 | 
			
		||||
): PushRuleData | undefined => {
 | 
			
		||||
  const { global } = pushRules;
 | 
			
		||||
 | 
			
		||||
  let ruleData: PushRuleData | undefined;
 | 
			
		||||
 | 
			
		||||
  orderedPushRuleKinds.some((kind) => {
 | 
			
		||||
    const rules = global[kind];
 | 
			
		||||
    const pushRule = rules?.find((r) => r.rule_id === ruleId);
 | 
			
		||||
    if (pushRule) {
 | 
			
		||||
      ruleData = {
 | 
			
		||||
        kind,
 | 
			
		||||
        pushRule,
 | 
			
		||||
      };
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return ruleData;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const usePushRule = (
 | 
			
		||||
  pushRules: IPushRules,
 | 
			
		||||
  ruleId: RuleId | string
 | 
			
		||||
): PushRuleData | undefined => useMemo(() => getPushRule(pushRules, ruleId), [pushRules, ruleId]);
 | 
			
		||||
							
								
								
									
										24
									
								
								src/app/hooks/useRestoreBackupOnVerification.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/app/hooks/useRestoreBackupOnVerification.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import { useSetAtom } from 'jotai';
 | 
			
		||||
import { useCallback } from 'react';
 | 
			
		||||
import { backupRestoreProgressAtom } from '../state/backupRestore';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
import { useKeyBackupDecryptionKeyCached } from './useKeyBackup';
 | 
			
		||||
 | 
			
		||||
export const useRestoreBackupOnVerification = () => {
 | 
			
		||||
  const setRestoreProgress = useSetAtom(backupRestoreProgressAtom);
 | 
			
		||||
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  useKeyBackupDecryptionKeyCached(
 | 
			
		||||
    useCallback(() => {
 | 
			
		||||
      const crypto = mx.getCrypto();
 | 
			
		||||
      if (!crypto) return;
 | 
			
		||||
 | 
			
		||||
      crypto.restoreKeyBackup({
 | 
			
		||||
        progressCallback(progress) {
 | 
			
		||||
          setRestoreProgress(progress);
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
    }, [mx, setRestoreProgress])
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										22
									
								
								src/app/hooks/useSecretStorage.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/app/hooks/useSecretStorage.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
import {
 | 
			
		||||
  AccountDataEvent,
 | 
			
		||||
  SecretStorageDefaultKeyContent,
 | 
			
		||||
  SecretStorageKeyContent,
 | 
			
		||||
} from '../../types/matrix/accountData';
 | 
			
		||||
import { useAccountData } from './useAccountData';
 | 
			
		||||
 | 
			
		||||
export const getSecretStorageKeyEventType = (key: string): string => `m.secret_storage.key.${key}`;
 | 
			
		||||
 | 
			
		||||
export const useSecretStorageDefaultKeyId = (): string | undefined => {
 | 
			
		||||
  const defaultKeyEvent = useAccountData(AccountDataEvent.SecretStorageDefaultKey);
 | 
			
		||||
  const defaultKeyId = defaultKeyEvent?.getContent<SecretStorageDefaultKeyContent>().key;
 | 
			
		||||
 | 
			
		||||
  return defaultKeyId;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useSecretStorageKeyContent = (keyId: string): SecretStorageKeyContent | undefined => {
 | 
			
		||||
  const keyEvent = useAccountData(getSecretStorageKeyEventType(keyId));
 | 
			
		||||
  const secretStorageKey = keyEvent?.getContent<SecretStorageKeyContent>();
 | 
			
		||||
 | 
			
		||||
  return secretStorageKey;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										58
									
								
								src/app/hooks/useTextAreaIntent.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/app/hooks/useTextAreaIntent.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,58 @@
 | 
			
		|||
import { isKeyHotkey } from 'is-hotkey';
 | 
			
		||||
import { KeyboardEventHandler, useCallback } from 'react';
 | 
			
		||||
import { Cursor, Intent, Operations, TextArea } from '../plugins/text-area';
 | 
			
		||||
 | 
			
		||||
export const useTextAreaIntentHandler = (
 | 
			
		||||
  textArea: TextArea,
 | 
			
		||||
  operations: Operations,
 | 
			
		||||
  intent: Intent
 | 
			
		||||
) => {
 | 
			
		||||
  const handler: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
 | 
			
		||||
    (evt) => {
 | 
			
		||||
      const target = evt.currentTarget;
 | 
			
		||||
 | 
			
		||||
      if (isKeyHotkey('tab', evt)) {
 | 
			
		||||
        evt.preventDefault();
 | 
			
		||||
 | 
			
		||||
        const cursor = Cursor.fromTextAreaElement(target);
 | 
			
		||||
        if (textArea.selection(cursor)) {
 | 
			
		||||
          operations.select(intent.moveForward(cursor));
 | 
			
		||||
        } else {
 | 
			
		||||
          operations.deselect(operations.insert(cursor, intent.str));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        target.focus();
 | 
			
		||||
      }
 | 
			
		||||
      if (isKeyHotkey('shift+tab', evt)) {
 | 
			
		||||
        evt.preventDefault();
 | 
			
		||||
        const cursor = Cursor.fromTextAreaElement(target);
 | 
			
		||||
        const intentCursor = intent.moveBackward(cursor);
 | 
			
		||||
        if (textArea.selection(cursor)) {
 | 
			
		||||
          operations.select(intentCursor);
 | 
			
		||||
        } else {
 | 
			
		||||
          operations.deselect(intentCursor);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        target.focus();
 | 
			
		||||
      }
 | 
			
		||||
      if (isKeyHotkey('enter', evt) || isKeyHotkey('shift+enter', evt)) {
 | 
			
		||||
        evt.preventDefault();
 | 
			
		||||
        const cursor = Cursor.fromTextAreaElement(target);
 | 
			
		||||
        operations.select(intent.addNewLine(cursor));
 | 
			
		||||
      }
 | 
			
		||||
      if (isKeyHotkey('mod+enter', evt)) {
 | 
			
		||||
        evt.preventDefault();
 | 
			
		||||
        const cursor = Cursor.fromTextAreaElement(target);
 | 
			
		||||
        operations.select(intent.addNextLine(cursor));
 | 
			
		||||
      }
 | 
			
		||||
      if (isKeyHotkey('mod+shift+enter', evt)) {
 | 
			
		||||
        evt.preventDefault();
 | 
			
		||||
        const cursor = Cursor.fromTextAreaElement(target);
 | 
			
		||||
        operations.select(intent.addPreviousLine(cursor));
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [textArea, operations, intent]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return handler;
 | 
			
		||||
};
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue