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
				
			
		
							
								
								
									
										7387
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										7387
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							| 
						 | 
					@ -44,7 +44,7 @@
 | 
				
			||||||
    "file-saver": "2.0.5",
 | 
					    "file-saver": "2.0.5",
 | 
				
			||||||
    "flux": "4.0.3",
 | 
					    "flux": "4.0.3",
 | 
				
			||||||
    "focus-trap-react": "10.0.2",
 | 
					    "focus-trap-react": "10.0.2",
 | 
				
			||||||
    "folds": "2.0.0",
 | 
					    "folds": "2.1.0",
 | 
				
			||||||
    "formik": "2.4.6",
 | 
					    "formik": "2.4.6",
 | 
				
			||||||
    "html-dom-parser": "4.0.0",
 | 
					    "html-dom-parser": "4.0.0",
 | 
				
			||||||
    "html-react-parser": "4.2.0",
 | 
					    "html-react-parser": "4.2.0",
 | 
				
			||||||
| 
						 | 
					@ -56,7 +56,7 @@
 | 
				
			||||||
    "jotai": "2.6.0",
 | 
					    "jotai": "2.6.0",
 | 
				
			||||||
    "linkify-react": "4.1.3",
 | 
					    "linkify-react": "4.1.3",
 | 
				
			||||||
    "linkifyjs": "4.1.3",
 | 
					    "linkifyjs": "4.1.3",
 | 
				
			||||||
    "matrix-js-sdk": "34.11.1",
 | 
					    "matrix-js-sdk": "35.0.0",
 | 
				
			||||||
    "millify": "6.1.0",
 | 
					    "millify": "6.1.0",
 | 
				
			||||||
    "pdfjs-dist": "4.2.67",
 | 
					    "pdfjs-dist": "4.2.67",
 | 
				
			||||||
    "prismjs": "1.29.0",
 | 
					    "prismjs": "1.29.0",
 | 
				
			||||||
| 
						 | 
					@ -108,6 +108,6 @@
 | 
				
			||||||
    "vite": "5.0.13",
 | 
					    "vite": "5.0.13",
 | 
				
			||||||
    "vite-plugin-pwa": "0.20.5",
 | 
					    "vite-plugin-pwa": "0.20.5",
 | 
				
			||||||
    "vite-plugin-static-copy": "1.0.4",
 | 
					    "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 () => {
 | 
					    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 capabilities = promiseFulfilledResult(result[0]);
 | 
				
			||||||
      const mediaConfig = promiseFulfilledResult(result[1]);
 | 
					      const mediaConfig = promiseFulfilledResult(result[1]);
 | 
				
			||||||
      return [capabilities, mediaConfig];
 | 
					      return [capabilities, mediaConfig];
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,7 +9,7 @@ type CapabilitiesLoaderProps = {
 | 
				
			||||||
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
 | 
					export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx]));
 | 
					  const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    load();
 | 
					    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,
 | 
					  IconButton,
 | 
				
			||||||
} from 'folds';
 | 
					} from 'folds';
 | 
				
			||||||
import FocusTrap from 'focus-trap-react';
 | 
					import FocusTrap from 'focus-trap-react';
 | 
				
			||||||
import { stopPropagation } from '../utils/keyboard';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type UIAFlowOverlayProps = {
 | 
					export type UIAFlowOverlayProps = {
 | 
				
			||||||
  currentStep: number;
 | 
					  currentStep: number;
 | 
				
			||||||
| 
						 | 
					@ -29,7 +28,7 @@ export function UIAFlowOverlay({
 | 
				
			||||||
}: UIAFlowOverlayProps) {
 | 
					}: UIAFlowOverlayProps) {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
					    <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 style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
 | 
				
			||||||
          <Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
 | 
					          <Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
 | 
				
			||||||
            {children}
 | 
					            {children}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16,14 +16,14 @@ import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'
 | 
				
			||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
 | 
					import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
 | 
				
			||||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
 | 
					import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
 | 
				
			||||||
import { IEmoji, emojis } from '../../../plugins/emoji';
 | 
					import { IEmoji, emojis } from '../../../plugins/emoji';
 | 
				
			||||||
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
 | 
					 | 
				
			||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
					import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
				
			||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
 | 
					import { mxcUrlToHttp } from '../../../utils/matrix';
 | 
				
			||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
				
			||||||
 | 
					import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
 | 
					type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type EmoticonSearchItem = ExtendedPackImage | IEmoji;
 | 
					type EmoticonSearchItem = PackImageReader | IEmoji;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type EmoticonAutocompleteProps = {
 | 
					type EmoticonAutocompleteProps = {
 | 
				
			||||||
  imagePackRooms: Room[];
 | 
					  imagePackRooms: Room[];
 | 
				
			||||||
| 
						 | 
					@ -52,21 +52,21 @@ export function EmoticonAutocomplete({
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const useAuthentication = useMediaAuthentication();
 | 
					  const useAuthentication = useMediaAuthentication();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms);
 | 
					  const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
 | 
				
			||||||
  const recentEmoji = useRecentEmoji(mx, 20);
 | 
					  const recentEmoji = useRecentEmoji(mx, 20);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const searchList = useMemo(() => {
 | 
					  const searchList = useMemo(() => {
 | 
				
			||||||
    const list: Array<EmoticonSearchItem> = [];
 | 
					    const list: Array<EmoticonSearchItem> = [];
 | 
				
			||||||
    return list
 | 
					    return list.concat(
 | 
				
			||||||
      .concat(
 | 
					      imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
 | 
				
			||||||
        imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)),
 | 
					      emojis
 | 
				
			||||||
        emojis
 | 
					    );
 | 
				
			||||||
      )
 | 
					 | 
				
			||||||
  }, [imagePacks]);
 | 
					  }, [imagePacks]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
 | 
					  const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
 | 
				
			||||||
  const autoCompleteEmoticon = (result ? result.items : recentEmoji)
 | 
					  const autoCompleteEmoticon = (result ? result.items : recentEmoji).sort((a, b) =>
 | 
				
			||||||
      .sort((a, b) => a.shortcode.localeCompare(b.shortcode));
 | 
					    a.shortcode.localeCompare(b.shortcode)
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (query.text) search(query.text);
 | 
					    if (query.text) search(query.text);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -41,7 +41,6 @@ import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard
 | 
				
			||||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
 | 
					import { useRelevantImagePacks } from '../../hooks/useImagePacks';
 | 
				
			||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
					import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
				
			||||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
 | 
					import { useRecentEmoji } from '../../hooks/useRecentEmoji';
 | 
				
			||||||
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
 | 
					 | 
				
			||||||
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
 | 
					import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
 | 
				
			||||||
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
 | 
					import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
 | 
				
			||||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
 | 
					import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
 | 
				
			||||||
| 
						 | 
					@ -50,6 +49,7 @@ import { useThrottle } from '../../hooks/useThrottle';
 | 
				
			||||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
 | 
					import { addRecentEmoji } from '../../plugins/recent-emoji';
 | 
				
			||||||
import { mobileOrTablet } from '../../utils/user-agent';
 | 
					import { mobileOrTablet } from '../../utils/user-agent';
 | 
				
			||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
				
			||||||
 | 
					import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const RECENT_GROUP_ID = 'recent_group';
 | 
					const RECENT_GROUP_ID = 'recent_group';
 | 
				
			||||||
const SEARCH_GROUP_ID = 'search_group';
 | 
					const SEARCH_GROUP_ID = 'search_group';
 | 
				
			||||||
| 
						 | 
					@ -359,16 +359,16 @@ function ImagePackSidebarStack({
 | 
				
			||||||
}: {
 | 
					}: {
 | 
				
			||||||
  mx: MatrixClient;
 | 
					  mx: MatrixClient;
 | 
				
			||||||
  packs: ImagePack[];
 | 
					  packs: ImagePack[];
 | 
				
			||||||
  usage: PackUsage;
 | 
					  usage: ImageUsage;
 | 
				
			||||||
  onItemClick: (id: string) => void;
 | 
					  onItemClick: (id: string) => void;
 | 
				
			||||||
  useAuthentication?: boolean;
 | 
					  useAuthentication?: boolean;
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const activeGroupId = useAtomValue(activeGroupIdAtom);
 | 
					  const activeGroupId = useAtomValue(activeGroupIdAtom);
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <SidebarStack>
 | 
					    <SidebarStack>
 | 
				
			||||||
      {usage === PackUsage.Emoticon && <SidebarDivider />}
 | 
					      {usage === ImageUsage.Emoticon && <SidebarDivider />}
 | 
				
			||||||
      {packs.map((pack) => {
 | 
					      {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;
 | 
					        if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
 | 
				
			||||||
        return (
 | 
					        return (
 | 
				
			||||||
          <SidebarBtn
 | 
					          <SidebarBtn
 | 
				
			||||||
| 
						 | 
					@ -384,7 +384,10 @@ function ImagePackSidebarStack({
 | 
				
			||||||
                height: toRem(24),
 | 
					                height: toRem(24),
 | 
				
			||||||
                objectFit: 'contain',
 | 
					                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'}
 | 
					              alt={label || 'Unknown Pack'}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </SidebarBtn>
 | 
					          </SidebarBtn>
 | 
				
			||||||
| 
						 | 
					@ -462,130 +465,156 @@ export function SearchEmojiGroup({
 | 
				
			||||||
  tab: EmojiBoardTab;
 | 
					  tab: EmojiBoardTab;
 | 
				
			||||||
  label: string;
 | 
					  label: string;
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
  emojis: Array<ExtendedPackImage | IEmoji>;
 | 
					  emojis: Array<PackImageReader | IEmoji>;
 | 
				
			||||||
  useAuthentication?: boolean;
 | 
					  useAuthentication?: boolean;
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <EmojiGroup key={id} id={id} label={label}>
 | 
					    <EmojiGroup key={id} id={id} label={label}>
 | 
				
			||||||
      {tab === EmojiBoardTab.Emoji
 | 
					      {tab === EmojiBoardTab.Emoji
 | 
				
			||||||
        ? searchResult.sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((emoji) =>
 | 
					        ? searchResult
 | 
				
			||||||
          'unicode' in emoji ? (
 | 
					            .sort((a, b) => a.shortcode.localeCompare(b.shortcode))
 | 
				
			||||||
            <EmojiItem
 | 
					            .map((emoji) =>
 | 
				
			||||||
              key={emoji.unicode}
 | 
					              'unicode' in emoji ? (
 | 
				
			||||||
              label={emoji.label}
 | 
					                <EmojiItem
 | 
				
			||||||
              type={EmojiType.Emoji}
 | 
					                  key={emoji.unicode}
 | 
				
			||||||
              data={emoji.unicode}
 | 
					                  label={emoji.label}
 | 
				
			||||||
              shortcode={emoji.shortcode}
 | 
					                  type={EmojiType.Emoji}
 | 
				
			||||||
            >
 | 
					                  data={emoji.unicode}
 | 
				
			||||||
              {emoji.unicode}
 | 
					                  shortcode={emoji.shortcode}
 | 
				
			||||||
            </EmojiItem>
 | 
					                >
 | 
				
			||||||
          ) : (
 | 
					                  {emoji.unicode}
 | 
				
			||||||
            <EmojiItem
 | 
					                </EmojiItem>
 | 
				
			||||||
              key={emoji.shortcode}
 | 
					              ) : (
 | 
				
			||||||
              label={emoji.body || emoji.shortcode}
 | 
					                <EmojiItem
 | 
				
			||||||
              type={EmojiType.CustomEmoji}
 | 
					                  key={emoji.shortcode}
 | 
				
			||||||
              data={emoji.url}
 | 
					                  label={emoji.body || emoji.shortcode}
 | 
				
			||||||
              shortcode={emoji.shortcode}
 | 
					                  type={EmojiType.CustomEmoji}
 | 
				
			||||||
            >
 | 
					                  data={emoji.url}
 | 
				
			||||||
              <img
 | 
					                  shortcode={emoji.shortcode}
 | 
				
			||||||
                loading="lazy"
 | 
					                >
 | 
				
			||||||
                className={css.CustomEmojiImg}
 | 
					                  <img
 | 
				
			||||||
                alt={emoji.body || emoji.shortcode}
 | 
					                    loading="lazy"
 | 
				
			||||||
                src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
 | 
					                    className={css.CustomEmojiImg}
 | 
				
			||||||
              />
 | 
					                    alt={emoji.body || emoji.shortcode}
 | 
				
			||||||
            </EmojiItem>
 | 
					                    src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
 | 
				
			||||||
          )
 | 
					                  />
 | 
				
			||||||
        )
 | 
					                </EmojiItem>
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
        : searchResult.map((emoji) =>
 | 
					        : searchResult.map((emoji) =>
 | 
				
			||||||
          'unicode' in emoji ? null : (
 | 
					            'unicode' in emoji ? null : (
 | 
				
			||||||
            <StickerItem
 | 
					              <StickerItem
 | 
				
			||||||
              key={emoji.shortcode}
 | 
					                key={emoji.shortcode}
 | 
				
			||||||
              label={emoji.body || emoji.shortcode}
 | 
					                label={emoji.body || emoji.shortcode}
 | 
				
			||||||
              type={EmojiType.Sticker}
 | 
					                type={EmojiType.Sticker}
 | 
				
			||||||
              data={emoji.url}
 | 
					                data={emoji.url}
 | 
				
			||||||
              shortcode={emoji.shortcode}
 | 
					                shortcode={emoji.shortcode}
 | 
				
			||||||
            >
 | 
					              >
 | 
				
			||||||
              <img
 | 
					                <img
 | 
				
			||||||
                loading="lazy"
 | 
					                  loading="lazy"
 | 
				
			||||||
                className={css.StickerImg}
 | 
					                  className={css.StickerImg}
 | 
				
			||||||
                alt={emoji.body || emoji.shortcode}
 | 
					                  alt={emoji.body || emoji.shortcode}
 | 
				
			||||||
                src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
 | 
					                  src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
 | 
				
			||||||
              />
 | 
					                />
 | 
				
			||||||
            </StickerItem>
 | 
					              </StickerItem>
 | 
				
			||||||
          )
 | 
					            )
 | 
				
			||||||
        )}
 | 
					          )}
 | 
				
			||||||
    </EmojiGroup>
 | 
					    </EmojiGroup>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const CustomEmojiGroups = memo(
 | 
					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) => (
 | 
					      {groups.map((pack) => (
 | 
				
			||||||
        <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
 | 
					        <EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
 | 
				
			||||||
          {pack.getEmojis().sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((image) => (
 | 
					          {pack
 | 
				
			||||||
            <EmojiItem
 | 
					            .getImages(ImageUsage.Emoticon)
 | 
				
			||||||
              key={image.shortcode}
 | 
					            .sort((a, b) => a.shortcode.localeCompare(b.shortcode))
 | 
				
			||||||
              label={image.body || image.shortcode}
 | 
					            .map((image) => (
 | 
				
			||||||
              type={EmojiType.CustomEmoji}
 | 
					              <EmojiItem
 | 
				
			||||||
              data={image.url}
 | 
					                key={image.shortcode}
 | 
				
			||||||
              shortcode={image.shortcode}
 | 
					                label={image.body || image.shortcode}
 | 
				
			||||||
            >
 | 
					                type={EmojiType.CustomEmoji}
 | 
				
			||||||
              <img
 | 
					                data={image.url}
 | 
				
			||||||
                loading="lazy"
 | 
					                shortcode={image.shortcode}
 | 
				
			||||||
                className={css.CustomEmojiImg}
 | 
					              >
 | 
				
			||||||
                alt={image.body || image.shortcode}
 | 
					                <img
 | 
				
			||||||
                src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
 | 
					                  loading="lazy"
 | 
				
			||||||
              />
 | 
					                  className={css.CustomEmojiImg}
 | 
				
			||||||
            </EmojiItem>
 | 
					                  alt={image.body || image.shortcode}
 | 
				
			||||||
          ))}
 | 
					                  src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              </EmojiItem>
 | 
				
			||||||
 | 
					            ))}
 | 
				
			||||||
        </EmojiGroup>
 | 
					        </EmojiGroup>
 | 
				
			||||||
      ))}
 | 
					      ))}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => (
 | 
					export const StickerGroups = memo(
 | 
				
			||||||
  <>
 | 
					  ({
 | 
				
			||||||
    {groups.length === 0 && (
 | 
					    mx,
 | 
				
			||||||
      <Box
 | 
					    groups,
 | 
				
			||||||
        style={{ padding: `${toRem(60)} ${config.space.S500}` }}
 | 
					    useAuthentication,
 | 
				
			||||||
        alignItems="Center"
 | 
					  }: {
 | 
				
			||||||
        justifyContent="Center"
 | 
					    mx: MatrixClient;
 | 
				
			||||||
        direction="Column"
 | 
					    groups: ImagePack[];
 | 
				
			||||||
        gap="300"
 | 
					    useAuthentication?: boolean;
 | 
				
			||||||
      >
 | 
					  }) => (
 | 
				
			||||||
        <Icon size="600" src={Icons.Sticker} />
 | 
					    <>
 | 
				
			||||||
        <Box direction="Inherit">
 | 
					      {groups.length === 0 && (
 | 
				
			||||||
          <Text align="Center">No Sticker Packs!</Text>
 | 
					        <Box
 | 
				
			||||||
          <Text priority="300" align="Center" size="T200">
 | 
					          style={{ padding: `${toRem(60)} ${config.space.S500}` }}
 | 
				
			||||||
            Add stickers from user, room or space settings.
 | 
					          alignItems="Center"
 | 
				
			||||||
          </Text>
 | 
					          justifyContent="Center"
 | 
				
			||||||
 | 
					          direction="Column"
 | 
				
			||||||
 | 
					          gap="300"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <Icon size="600" src={Icons.Sticker} />
 | 
				
			||||||
 | 
					          <Box direction="Inherit">
 | 
				
			||||||
 | 
					            <Text align="Center">No Sticker Packs!</Text>
 | 
				
			||||||
 | 
					            <Text priority="300" align="Center" size="T200">
 | 
				
			||||||
 | 
					              Add stickers from user, room or space settings.
 | 
				
			||||||
 | 
					            </Text>
 | 
				
			||||||
 | 
					          </Box>
 | 
				
			||||||
        </Box>
 | 
					        </Box>
 | 
				
			||||||
      </Box>
 | 
					      )}
 | 
				
			||||||
    )}
 | 
					      {groups.map((pack) => (
 | 
				
			||||||
    {groups.map((pack) => (
 | 
					        <EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
 | 
				
			||||||
      <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
 | 
					          {pack
 | 
				
			||||||
        {pack.getStickers().sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((image) => (
 | 
					            .getImages(ImageUsage.Sticker)
 | 
				
			||||||
          <StickerItem
 | 
					            .sort((a, b) => a.shortcode.localeCompare(b.shortcode))
 | 
				
			||||||
            key={image.shortcode}
 | 
					            .map((image) => (
 | 
				
			||||||
            label={image.body || image.shortcode}
 | 
					              <StickerItem
 | 
				
			||||||
            type={EmojiType.Sticker}
 | 
					                key={image.shortcode}
 | 
				
			||||||
            data={image.url}
 | 
					                label={image.body || image.shortcode}
 | 
				
			||||||
            shortcode={image.shortcode}
 | 
					                type={EmojiType.Sticker}
 | 
				
			||||||
          >
 | 
					                data={image.url}
 | 
				
			||||||
            <img
 | 
					                shortcode={image.shortcode}
 | 
				
			||||||
              loading="lazy"
 | 
					              >
 | 
				
			||||||
              className={css.StickerImg}
 | 
					                <img
 | 
				
			||||||
              alt={image.body || image.shortcode}
 | 
					                  loading="lazy"
 | 
				
			||||||
              src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
 | 
					                  className={css.StickerImg}
 | 
				
			||||||
            />
 | 
					                  alt={image.body || image.shortcode}
 | 
				
			||||||
          </StickerItem>
 | 
					                  src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
 | 
				
			||||||
        ))}
 | 
					                />
 | 
				
			||||||
      </EmojiGroup>
 | 
					              </StickerItem>
 | 
				
			||||||
    ))}
 | 
					            ))}
 | 
				
			||||||
  </>
 | 
					        </EmojiGroup>
 | 
				
			||||||
));
 | 
					      ))}
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const NativeEmojiGroups = memo(
 | 
					export const NativeEmojiGroups = memo(
 | 
				
			||||||
  ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
 | 
					  ({ 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}:`;
 | 
					  const shortcode = `:${item.shortcode}:`;
 | 
				
			||||||
  if ('body' in item) {
 | 
					  if ('body' in item) {
 | 
				
			||||||
    return [shortcode, item.body ?? ''];
 | 
					    return [shortcode, item.body ?? ''];
 | 
				
			||||||
| 
						 | 
					@ -646,14 +675,14 @@ export function EmojiBoard({
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
  const emojiTab = tab === EmojiBoardTab.Emoji;
 | 
					  const emojiTab = tab === EmojiBoardTab.Emoji;
 | 
				
			||||||
  const stickerTab = tab === EmojiBoardTab.Sticker;
 | 
					  const stickerTab = tab === EmojiBoardTab.Sticker;
 | 
				
			||||||
  const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker;
 | 
					  const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const setActiveGroupId = useSetAtom(activeGroupIdAtom);
 | 
					  const setActiveGroupId = useSetAtom(activeGroupIdAtom);
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const useAuthentication = useMediaAuthentication();
 | 
					  const useAuthentication = useMediaAuthentication();
 | 
				
			||||||
  const emojiGroupLabels = useEmojiGroupLabels();
 | 
					  const emojiGroupLabels = useEmojiGroupLabels();
 | 
				
			||||||
  const emojiGroupIcons = useEmojiGroupIcons();
 | 
					  const emojiGroupIcons = useEmojiGroupIcons();
 | 
				
			||||||
  const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms);
 | 
					  const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
 | 
				
			||||||
  const recentEmojis = useRecentEmoji(mx, 21);
 | 
					  const recentEmojis = useRecentEmoji(mx, 21);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const contentScrollRef = useRef<HTMLDivElement>(null);
 | 
					  const contentScrollRef = useRef<HTMLDivElement>(null);
 | 
				
			||||||
| 
						 | 
					@ -661,8 +690,8 @@ export function EmojiBoard({
 | 
				
			||||||
  const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
 | 
					  const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const searchList = useMemo(() => {
 | 
					  const searchList = useMemo(() => {
 | 
				
			||||||
    let list: Array<ExtendedPackImage | IEmoji> = [];
 | 
					    let list: Array<PackImageReader | IEmoji> = [];
 | 
				
			||||||
    list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage)));
 | 
					    list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
 | 
				
			||||||
    if (emojiTab) list = list.concat(emojis);
 | 
					    if (emojiTab) list = list.concat(emojis);
 | 
				
			||||||
    return list;
 | 
					    return list;
 | 
				
			||||||
  }, [emojiTab, usage, imagePacks]);
 | 
					  }, [emojiTab, usage, imagePacks]);
 | 
				
			||||||
| 
						 | 
					@ -688,7 +717,7 @@ export function EmojiBoard({
 | 
				
			||||||
  const syncActiveGroupId = useCallback(() => {
 | 
					  const syncActiveGroupId = useCallback(() => {
 | 
				
			||||||
    const targetEl = contentScrollRef.current;
 | 
					    const targetEl = contentScrollRef.current;
 | 
				
			||||||
    if (!targetEl) return;
 | 
					    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 groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
 | 
				
			||||||
    const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
 | 
					    const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
 | 
				
			||||||
    setActiveGroupId(groupId);
 | 
					    setActiveGroupId(groupId);
 | 
				
			||||||
| 
						 | 
					@ -735,7 +764,10 @@ export function EmojiBoard({
 | 
				
			||||||
      } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
 | 
					      } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
 | 
				
			||||||
        const img = document.createElement('img');
 | 
					        const img = document.createElement('img');
 | 
				
			||||||
        img.className = css.CustomEmojiImg;
 | 
					        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);
 | 
					        img.setAttribute('alt', emojiInfo.shortcode);
 | 
				
			||||||
        emojiPreviewRef.current.textContent = '';
 | 
					        emojiPreviewRef.current.textContent = '';
 | 
				
			||||||
        emojiPreviewRef.current.appendChild(img);
 | 
					        emojiPreviewRef.current.appendChild(img);
 | 
				
			||||||
| 
						 | 
					@ -903,8 +935,16 @@ export function EmojiBoard({
 | 
				
			||||||
              {emojiTab && recentEmojis.length > 0 && (
 | 
					              {emojiTab && recentEmojis.length > 0 && (
 | 
				
			||||||
                <RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
 | 
					                <RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
 | 
				
			||||||
              )}
 | 
					              )}
 | 
				
			||||||
              {emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />}
 | 
					              {emojiTab && (
 | 
				
			||||||
              {stickerTab && <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />}
 | 
					                <CustomEmojiGroups
 | 
				
			||||||
 | 
					                  mx={mx}
 | 
				
			||||||
 | 
					                  groups={imagePacks}
 | 
				
			||||||
 | 
					                  useAuthentication={useAuthentication}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
 | 
					              {stickerTab && (
 | 
				
			||||||
 | 
					                <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
              {emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
 | 
					              {emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
 | 
				
			||||||
            </Box>
 | 
					            </Box>
 | 
				
			||||||
          </Scroll>
 | 
					          </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 { Box, Icon, IconSrc } from 'folds';
 | 
				
			||||||
import React, { ReactNode } from 'react';
 | 
					import React, { ReactNode } from 'react';
 | 
				
			||||||
import { CompactLayout, ModernLayout } from '..';
 | 
					import { CompactLayout, ModernLayout } from '..';
 | 
				
			||||||
 | 
					import { MessageLayout } from '../../../state/settings';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type EventContentProps = {
 | 
					export type EventContentProps = {
 | 
				
			||||||
  messageLayout: number;
 | 
					  messageLayout: number;
 | 
				
			||||||
| 
						 | 
					@ -11,9 +12,9 @@ export type EventContentProps = {
 | 
				
			||||||
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
 | 
					export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
 | 
				
			||||||
  const beforeJSX = (
 | 
					  const beforeJSX = (
 | 
				
			||||||
    <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
 | 
					    <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
 | 
				
			||||||
      {messageLayout === 1 && time}
 | 
					      {messageLayout === MessageLayout.Compact && time}
 | 
				
			||||||
      <Box
 | 
					      <Box
 | 
				
			||||||
        grow={messageLayout === 1 ? undefined : 'Yes'}
 | 
					        grow={messageLayout === MessageLayout.Compact ? undefined : 'Yes'}
 | 
				
			||||||
        alignItems="Center"
 | 
					        alignItems="Center"
 | 
				
			||||||
        justifyContent="Center"
 | 
					        justifyContent="Center"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
| 
						 | 
					@ -25,11 +26,11 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
 | 
				
			||||||
  const msgContentJSX = (
 | 
					  const msgContentJSX = (
 | 
				
			||||||
    <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
 | 
					    <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
 | 
				
			||||||
      {content}
 | 
					      {content}
 | 
				
			||||||
      {messageLayout !== 1 && time}
 | 
					      {messageLayout !== MessageLayout.Compact && time}
 | 
				
			||||||
    </Box>
 | 
					    </Box>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return messageLayout === 1 ? (
 | 
					  return messageLayout === MessageLayout.Compact ? (
 | 
				
			||||||
    <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
 | 
					    <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
 | 
				
			||||||
  ) : (
 | 
					  ) : (
 | 
				
			||||||
    <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
 | 
					    <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -27,7 +27,6 @@ import {
 | 
				
			||||||
  getFileNameExt,
 | 
					  getFileNameExt,
 | 
				
			||||||
  mimeTypeToExt,
 | 
					  mimeTypeToExt,
 | 
				
			||||||
} from '../../../utils/mimeTypes';
 | 
					} from '../../../utils/mimeTypes';
 | 
				
			||||||
import * as css from './style.css';
 | 
					 | 
				
			||||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
					import { stopPropagation } from '../../../utils/keyboard';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  decryptFile,
 | 
					  decryptFile,
 | 
				
			||||||
| 
						 | 
					@ -36,6 +35,7 @@ import {
 | 
				
			||||||
  mxcUrlToHttp,
 | 
					  mxcUrlToHttp,
 | 
				
			||||||
} from '../../../utils/matrix';
 | 
					} from '../../../utils/matrix';
 | 
				
			||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
				
			||||||
 | 
					import { ModalWide } from '../../../styles/Modal.css';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const renderErrorButton = (retry: () => void, text: string) => (
 | 
					const renderErrorButton = (retry: () => void, text: string) => (
 | 
				
			||||||
  <TooltipProvider
 | 
					  <TooltipProvider
 | 
				
			||||||
| 
						 | 
					@ -111,7 +111,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
 | 
				
			||||||
              }}
 | 
					              }}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <Modal
 | 
					              <Modal
 | 
				
			||||||
                className={css.ModalWide}
 | 
					                className={ModalWide}
 | 
				
			||||||
                size="500"
 | 
					                size="500"
 | 
				
			||||||
                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
					                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
| 
						 | 
					@ -199,7 +199,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
 | 
				
			||||||
              }}
 | 
					              }}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <Modal
 | 
					              <Modal
 | 
				
			||||||
                className={css.ModalWide}
 | 
					                className={ModalWide}
 | 
				
			||||||
                size="500"
 | 
					                size="500"
 | 
				
			||||||
                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
					                onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -28,6 +28,7 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
 | 
				
			||||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
					import { stopPropagation } from '../../../utils/keyboard';
 | 
				
			||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
 | 
					import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
 | 
				
			||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
					import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
				
			||||||
 | 
					import { ModalWide } from '../../../styles/Modal.css';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type RenderViewerProps = {
 | 
					type RenderViewerProps = {
 | 
				
			||||||
  src: string;
 | 
					  src: string;
 | 
				
			||||||
| 
						 | 
					@ -121,7 +122,7 @@ export const ImageContent = as<'div', ImageContentProps>(
 | 
				
			||||||
                }}
 | 
					                }}
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <Modal
 | 
					                <Modal
 | 
				
			||||||
                  className={css.ModalWide}
 | 
					                  className={ModalWide}
 | 
				
			||||||
                  size="500"
 | 
					                  size="500"
 | 
				
			||||||
                  onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
					                  onContextMenu={(evt: any) => evt.stopPropagation()}
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -30,8 +30,3 @@ export const AbsoluteFooter = style([
 | 
				
			||||||
    right: config.space.S100,
 | 
					    right: config.space.S100,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
]);
 | 
					]);
 | 
				
			||||||
 | 
					 | 
				
			||||||
export const ModalWide = style({
 | 
					 | 
				
			||||||
  minWidth: '85vw',
 | 
					 | 
				
			||||||
  minHeight: '90vh',
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -27,14 +27,14 @@ export function PageRoot({ nav, children }: PageRootProps) {
 | 
				
			||||||
type ClientDrawerLayoutProps = {
 | 
					type ClientDrawerLayoutProps = {
 | 
				
			||||||
  children: ReactNode;
 | 
					  children: ReactNode;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export function PageNav({ children }: ClientDrawerLayoutProps) {
 | 
					export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
 | 
				
			||||||
  const screenSize = useScreenSizeContext();
 | 
					  const screenSize = useScreenSizeContext();
 | 
				
			||||||
  const isMobile = screenSize === ScreenSize.Mobile;
 | 
					  const isMobile = screenSize === ScreenSize.Mobile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Box
 | 
					    <Box
 | 
				
			||||||
      grow={isMobile ? 'Yes' : undefined}
 | 
					      grow={isMobile ? 'Yes' : undefined}
 | 
				
			||||||
      className={css.PageNav}
 | 
					      className={css.PageNav({ size })}
 | 
				
			||||||
      shrink={isMobile ? 'Yes' : 'No'}
 | 
					      shrink={isMobile ? 'Yes' : 'No'}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <Box grow="Yes" direction="Column">
 | 
					      <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>(
 | 
				
			||||||
  <Header
 | 
					  ({ className, outlined, ...props }, ref) => (
 | 
				
			||||||
    className={classNames(css.PageNavHeader, className)}
 | 
					    <Header
 | 
				
			||||||
    variant="Background"
 | 
					      className={classNames(css.PageNavHeader({ outlined }), className)}
 | 
				
			||||||
    size="600"
 | 
					      variant="Background"
 | 
				
			||||||
    {...props}
 | 
					      size="600"
 | 
				
			||||||
    ref={ref}
 | 
					      {...props}
 | 
				
			||||||
  />
 | 
					      ref={ref}
 | 
				
			||||||
));
 | 
					    />
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function PageNavContent({
 | 
					export function PageNavContent({
 | 
				
			||||||
  scrollRef,
 | 
					  scrollRef,
 | 
				
			||||||
| 
						 | 
					@ -88,11 +90,11 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
 | 
				
			||||||
));
 | 
					));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const PageHeader = as<'div', css.PageHeaderVariants>(
 | 
					export const PageHeader = as<'div', css.PageHeaderVariants>(
 | 
				
			||||||
  ({ className, balance, ...props }, ref) => (
 | 
					  ({ className, outlined, balance, ...props }, ref) => (
 | 
				
			||||||
    <Header
 | 
					    <Header
 | 
				
			||||||
      as="header"
 | 
					      as="header"
 | 
				
			||||||
      size="600"
 | 
					      size="600"
 | 
				
			||||||
      className={classNames(css.PageHeader({ balance }), className)}
 | 
					      className={classNames(css.PageHeader({ balance, outlined }), className)}
 | 
				
			||||||
      {...props}
 | 
					      {...props}
 | 
				
			||||||
      ref={ref}
 | 
					      ref={ref}
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,30 +2,55 @@ import { style } from '@vanilla-extract/css';
 | 
				
			||||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
 | 
					import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
 | 
				
			||||||
import { DefaultReset, color, config, toRem } from 'folds';
 | 
					import { DefaultReset, color, config, toRem } from 'folds';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const PageNav = style({
 | 
					export const PageNav = recipe({
 | 
				
			||||||
  width: toRem(256),
 | 
					  variants: {
 | 
				
			||||||
});
 | 
					    size: {
 | 
				
			||||||
 | 
					      '400': {
 | 
				
			||||||
export const PageNavHeader = style({
 | 
					        width: toRem(256),
 | 
				
			||||||
  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
 | 
					      },
 | 
				
			||||||
  flexShrink: 0,
 | 
					      '300': {
 | 
				
			||||||
  borderBottomWidth: 1,
 | 
					        width: toRem(222),
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
  selectors: {
 | 
					 | 
				
			||||||
    'button&': {
 | 
					 | 
				
			||||||
      cursor: 'pointer',
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    'button&[aria-pressed=true]': {
 | 
					 | 
				
			||||||
      backgroundColor: color.Background.ContainerActive,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    'button&:hover, button&:focus-visible': {
 | 
					 | 
				
			||||||
      backgroundColor: color.Background.ContainerHover,
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    'button&:active': {
 | 
					 | 
				
			||||||
      backgroundColor: color.Background.ContainerActive,
 | 
					 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  defaultVariants: {
 | 
				
			||||||
 | 
					    size: '400',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					export type PageNavVariants = RecipeVariants<typeof PageNav>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const PageNavHeader = recipe({
 | 
				
			||||||
 | 
					  base: {
 | 
				
			||||||
 | 
					    padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
 | 
				
			||||||
 | 
					    flexShrink: 0,
 | 
				
			||||||
 | 
					    selectors: {
 | 
				
			||||||
 | 
					      'button&': {
 | 
				
			||||||
 | 
					        cursor: 'pointer',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      'button&[aria-pressed=true]': {
 | 
				
			||||||
 | 
					        backgroundColor: color.Background.ContainerActive,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      'button&:hover, button&:focus-visible': {
 | 
				
			||||||
 | 
					        backgroundColor: color.Background.ContainerHover,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      'button&:active': {
 | 
				
			||||||
 | 
					        backgroundColor: color.Background.ContainerActive,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  variants: {
 | 
				
			||||||
 | 
					    outlined: {
 | 
				
			||||||
 | 
					      true: {
 | 
				
			||||||
 | 
					        borderBottomWidth: 1,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  defaultVariants: {
 | 
				
			||||||
 | 
					    outlined: true,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					export type PageNavHeaderVariants = RecipeVariants<typeof PageNavHeader>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const PageNavContent = style({
 | 
					export const PageNavContent = style({
 | 
				
			||||||
  minHeight: '100%',
 | 
					  minHeight: '100%',
 | 
				
			||||||
| 
						 | 
					@ -38,7 +63,6 @@ export const PageHeader = recipe({
 | 
				
			||||||
  base: {
 | 
					  base: {
 | 
				
			||||||
    paddingLeft: config.space.S400,
 | 
					    paddingLeft: config.space.S400,
 | 
				
			||||||
    paddingRight: config.space.S200,
 | 
					    paddingRight: config.space.S200,
 | 
				
			||||||
    borderBottomWidth: config.borderWidth.B300,
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  variants: {
 | 
					  variants: {
 | 
				
			||||||
    balance: {
 | 
					    balance: {
 | 
				
			||||||
| 
						 | 
					@ -46,6 +70,14 @@ export const PageHeader = recipe({
 | 
				
			||||||
        paddingLeft: config.space.S200,
 | 
					        paddingLeft: config.space.S200,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    outlined: {
 | 
				
			||||||
 | 
					      true: {
 | 
				
			||||||
 | 
					        borderBottomWidth: config.borderWidth.B300,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  defaultVariants: {
 | 
				
			||||||
 | 
					    outlined: true,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;
 | 
					export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,7 +6,7 @@ type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> &
 | 
				
			||||||
  size: '400' | '500';
 | 
					  size: '400' | '500';
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
 | 
					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;
 | 
					    const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    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 './types';
 | 
				
			||||||
export * from './DummyStage';
 | 
					export * from './DummyStage';
 | 
				
			||||||
export * from './EmailStage';
 | 
					export * from './EmailStage';
 | 
				
			||||||
 | 
					export * from './PasswordStage';
 | 
				
			||||||
export * from './ReCaptchaStage';
 | 
					export * from './ReCaptchaStage';
 | 
				
			||||||
export * from './RegistrationTokenStage';
 | 
					export * from './RegistrationTokenStage';
 | 
				
			||||||
 | 
					export * from './SSOStage';
 | 
				
			||||||
export * from './TermsStage';
 | 
					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,
 | 
					    padding: config.space.S300,
 | 
				
			||||||
    backgroundColor: color.SurfaceVariant.Container,
 | 
					    backgroundColor: color.SurfaceVariant.Container,
 | 
				
			||||||
    color: color.SurfaceVariant.OnContainer,
 | 
					    color: color.SurfaceVariant.OnContainer,
 | 
				
			||||||
 | 
					    borderColor: color.SurfaceVariant.ContainerLine,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  variants: {
 | 
					  variants: {
 | 
				
			||||||
    radii: RadiiVariant,
 | 
					    radii: RadiiVariant,
 | 
				
			||||||
 | 
					    outlined: {
 | 
				
			||||||
 | 
					      true: {
 | 
				
			||||||
 | 
					        borderStyle: 'solid',
 | 
				
			||||||
 | 
					        borderWidth: config.borderWidth.B300,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    compact: {
 | 
				
			||||||
 | 
					      true: {
 | 
				
			||||||
 | 
					        padding: config.space.S100,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  defaultVariants: {
 | 
					  defaultVariants: {
 | 
				
			||||||
    radii: '400',
 | 
					    radii: '400',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,8 +12,13 @@ type UploadCardProps = {
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>(
 | 
					export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>(
 | 
				
			||||||
  ({ before, after, children, bottom, radii }, ref) => (
 | 
					  ({ before, after, children, bottom, radii, outlined, compact }, ref) => (
 | 
				
			||||||
    <Box className={css.UploadCard({ radii })} direction="Column" gap="200" ref={ref}>
 | 
					    <Box
 | 
				
			||||||
 | 
					      className={css.UploadCard({ radii, outlined, compact })}
 | 
				
			||||||
 | 
					      direction="Column"
 | 
				
			||||||
 | 
					      gap="200"
 | 
				
			||||||
 | 
					      ref={ref}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <Box alignItems="Center" gap="200">
 | 
					      <Box alignItems="Center" gap="200">
 | 
				
			||||||
        {before}
 | 
					        {before}
 | 
				
			||||||
        <Box alignItems="Center" grow="Yes" gap="200">
 | 
					        <Box alignItems="Center" grow="Yes" gap="200">
 | 
				
			||||||
| 
						 | 
					@ -33,7 +38,7 @@ type UploadCardProgressProps = {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
 | 
					export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Box direction="Column" gap="200">
 | 
					    <Box grow="Yes" direction="Column" gap="200">
 | 
				
			||||||
      <ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
 | 
					      <ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
 | 
				
			||||||
      <Box alignItems="Center" justifyContent="SpaceBetween">
 | 
					      <Box alignItems="Center" justifyContent="SpaceBetween">
 | 
				
			||||||
        <Badge variant="Secondary" fill="Solid" radii="Pill">
 | 
					        <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 = {
 | 
					type UploadCardErrorProps = {
 | 
				
			||||||
  children: ReactNode;
 | 
					  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 { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
 | 
				
			||||||
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
 | 
					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 { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
				
			||||||
import { TUploadContent } from '../../utils/matrix';
 | 
					import { TUploadContent } from '../../utils/matrix';
 | 
				
			||||||
import { getFileTypeIcon } from '../../utils/common';
 | 
					import { getFileTypeIcon } from '../../utils/common';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type UploadCardRendererProps = {
 | 
					type UploadCardRendererProps = {
 | 
				
			||||||
  file: TUploadContent;
 | 
					 | 
				
			||||||
  isEncrypted?: boolean;
 | 
					  isEncrypted?: boolean;
 | 
				
			||||||
  uploadAtom: TUploadAtom;
 | 
					  uploadAtom: TUploadAtom;
 | 
				
			||||||
  onRemove: (file: TUploadContent) => void;
 | 
					  onRemove: (file: TUploadContent) => void;
 | 
				
			||||||
 | 
					  onComplete?: (upload: UploadSuccess) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export function UploadCardRenderer({
 | 
					export function UploadCardRenderer({
 | 
				
			||||||
  file,
 | 
					 | 
				
			||||||
  isEncrypted,
 | 
					  isEncrypted,
 | 
				
			||||||
  uploadAtom,
 | 
					  uploadAtom,
 | 
				
			||||||
  onRemove,
 | 
					  onRemove,
 | 
				
			||||||
 | 
					  onComplete,
 | 
				
			||||||
}: UploadCardRendererProps) {
 | 
					}: UploadCardRendererProps) {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const { upload, startUpload, cancelUpload } = useBindUploadAtom(
 | 
					  const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
 | 
				
			||||||
    mx,
 | 
					  const { file } = upload;
 | 
				
			||||||
    file,
 | 
					 | 
				
			||||||
    uploadAtom,
 | 
					 | 
				
			||||||
    isEncrypted
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (upload.status === UploadStatus.Idle) startUpload();
 | 
					  if (upload.status === UploadStatus.Idle) startUpload();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,6 +29,12 @@ export function UploadCardRenderer({
 | 
				
			||||||
    onRemove(file);
 | 
					    onRemove(file);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (upload.status === UploadStatus.Success) {
 | 
				
			||||||
 | 
					      onComplete?.(upload);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [upload, onComplete]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <UploadCard
 | 
					    <UploadCard
 | 
				
			||||||
      radii="300"
 | 
					      radii="300"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,2 +1,3 @@
 | 
				
			||||||
export * from './UploadCard';
 | 
					export * from './UploadCard';
 | 
				
			||||||
export * from './UploadCardRenderer';
 | 
					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 { useVirtualizer } from '@tanstack/react-virtual';
 | 
				
			||||||
import { useAtom, useAtomValue } from 'jotai';
 | 
					import { useAtom, useAtomValue } from 'jotai';
 | 
				
			||||||
import { useNavigate } from 'react-router-dom';
 | 
					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 { useSpace } from '../../hooks/useSpace';
 | 
				
			||||||
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
 | 
					import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
 | 
				
			||||||
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
 | 
					import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
 | 
				
			||||||
| 
						 | 
					@ -258,7 +259,7 @@ export function Lobby() {
 | 
				
			||||||
        const joinRuleContent = getStateEvent(
 | 
					        const joinRuleContent = getStateEvent(
 | 
				
			||||||
          itemRoom,
 | 
					          itemRoom,
 | 
				
			||||||
          StateEvent.RoomJoinRules
 | 
					          StateEvent.RoomJoinRules
 | 
				
			||||||
        )?.getContent<IJoinRuleEventContent>();
 | 
					        )?.getContent<RoomJoinRulesEventContent>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (joinRuleContent) {
 | 
					        if (joinRuleContent) {
 | 
				
			||||||
          const allow =
 | 
					          const allow =
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -56,7 +56,13 @@ import {
 | 
				
			||||||
} from '../../components/editor';
 | 
					} from '../../components/editor';
 | 
				
			||||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
 | 
					import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
 | 
				
			||||||
import { UseStateProvider } from '../../components/UseStateProvider';
 | 
					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 { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
 | 
				
			||||||
import { useFilePicker } from '../../hooks/useFilePicker';
 | 
					import { useFilePicker } from '../../hooks/useFilePicker';
 | 
				
			||||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
 | 
					import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
 | 
				
			||||||
| 
						 | 
					@ -157,7 +163,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 | 
				
			||||||
        const safeFiles = files.map(safeFile);
 | 
					        const safeFiles = files.map(safeFile);
 | 
				
			||||||
        const fileItems: TUploadItem[] = [];
 | 
					        const fileItems: TUploadItem[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (mx.isRoomEncrypted(roomId)) {
 | 
					        if (room.hasEncryptionStateEvent()) {
 | 
				
			||||||
          const encryptFiles = fulfilledPromiseSettledResult(
 | 
					          const encryptFiles = fulfilledPromiseSettledResult(
 | 
				
			||||||
            await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
 | 
					            await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
| 
						 | 
					@ -172,7 +178,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 | 
				
			||||||
          item: fileItems,
 | 
					          item: fileItems,
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      [setSelectedFiles, roomId, mx]
 | 
					      [setSelectedFiles, room]
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    const pickFile = useFilePicker(handleFiles, true);
 | 
					    const pickFile = useFilePicker(handleFiles, true);
 | 
				
			||||||
    const handlePaste = useFilePasteHandler(handleFiles);
 | 
					    const handlePaste = useFilePasteHandler(handleFiles);
 | 
				
			||||||
| 
						 | 
					@ -413,7 +419,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 | 
				
			||||||
                      <UploadCardRenderer
 | 
					                      <UploadCardRenderer
 | 
				
			||||||
                        // eslint-disable-next-line react/no-array-index-key
 | 
					                        // eslint-disable-next-line react/no-array-index-key
 | 
				
			||||||
                        key={index}
 | 
					                        key={index}
 | 
				
			||||||
                        file={fileItem.file}
 | 
					 | 
				
			||||||
                        isEncrypted={!!fileItem.encInfo}
 | 
					                        isEncrypted={!!fileItem.encInfo}
 | 
				
			||||||
                        uploadAtom={roomUploadAtomFamily(fileItem.file)}
 | 
					                        uploadAtom={roomUploadAtomFamily(fileItem.file)}
 | 
				
			||||||
                        onRemove={handleRemoveUpload}
 | 
					                        onRemove={handleRemoveUpload}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -85,7 +85,7 @@ import {
 | 
				
			||||||
  reactionOrEditEvent,
 | 
					  reactionOrEditEvent,
 | 
				
			||||||
} from '../../utils/room';
 | 
					} from '../../utils/room';
 | 
				
			||||||
import { useSetting } from '../../state/hooks/settings';
 | 
					import { useSetting } from '../../state/hooks/settings';
 | 
				
			||||||
import { settingsAtom } from '../../state/settings';
 | 
					import { MessageLayout, settingsAtom } from '../../state/settings';
 | 
				
			||||||
import { openProfileViewer } from '../../../client/action/navigation';
 | 
					import { openProfileViewer } from '../../../client/action/navigation';
 | 
				
			||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
 | 
					import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
 | 
				
			||||||
import { Reactions, Message, Event, EncryptedContent } from './message';
 | 
					import { Reactions, Message, Event, EncryptedContent } from './message';
 | 
				
			||||||
| 
						 | 
					@ -336,7 +336,10 @@ const useTimelinePagination = (
 | 
				
			||||||
          backwards ? Direction.Backward : Direction.Forward
 | 
					          backwards ? Direction.Backward : Direction.Forward
 | 
				
			||||||
        ) ?? timelineToPaginate;
 | 
					        ) ?? timelineToPaginate;
 | 
				
			||||||
      // Decrypt all event ahead of render cycle
 | 
					      // 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));
 | 
					        await to(decryptAllTimelineEvent(mx, fetchedTimeline));
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -421,7 +424,6 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
 | 
				
			||||||
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
 | 
					export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const useAuthentication = useMediaAuthentication();
 | 
					  const useAuthentication = useMediaAuthentication();
 | 
				
			||||||
  const encryptedRoom = mx.isRoomEncrypted(room.roomId);
 | 
					 | 
				
			||||||
  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
 | 
					  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
 | 
				
			||||||
  const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
 | 
					  const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
 | 
				
			||||||
  const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
 | 
					  const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
 | 
				
			||||||
| 
						 | 
					@ -429,7 +431,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
 | 
					  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
 | 
				
			||||||
  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
 | 
					  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
 | 
				
			||||||
  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
 | 
					  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
 | 
				
			||||||
  const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
 | 
					  const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
 | 
				
			||||||
  const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
 | 
					  const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
 | 
				
			||||||
  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
 | 
					  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
 | 
				
			||||||
  const powerLevels = usePowerLevelsContext();
 | 
					  const powerLevels = usePowerLevelsContext();
 | 
				
			||||||
| 
						 | 
					@ -1030,7 +1032,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
                urlPreview={showUrlPreview}
 | 
					                urlPreview={showUrlPreview}
 | 
				
			||||||
                htmlReactParserOptions={htmlReactParserOptions}
 | 
					                htmlReactParserOptions={htmlReactParserOptions}
 | 
				
			||||||
                linkifyOpts={linkifyOpts}
 | 
					                linkifyOpts={linkifyOpts}
 | 
				
			||||||
                outlineAttachment={messageLayout === 2}
 | 
					                outlineAttachment={messageLayout === MessageLayout.Bubble}
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
            )}
 | 
					            )}
 | 
				
			||||||
          </Message>
 | 
					          </Message>
 | 
				
			||||||
| 
						 | 
					@ -1126,7 +1128,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
                      urlPreview={showUrlPreview}
 | 
					                      urlPreview={showUrlPreview}
 | 
				
			||||||
                      htmlReactParserOptions={htmlReactParserOptions}
 | 
					                      htmlReactParserOptions={htmlReactParserOptions}
 | 
				
			||||||
                      linkifyOpts={linkifyOpts}
 | 
					                      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 highlighted = focusItem?.index === item && focusItem.highlight;
 | 
				
			||||||
        const parsed = parseMemberEvent(mEvent);
 | 
					        const parsed = parseMemberEvent(mEvent);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
 | 
					        const timeJSX = (
 | 
				
			||||||
 | 
					          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return (
 | 
					        return (
 | 
				
			||||||
          <Event
 | 
					          <Event
 | 
				
			||||||
| 
						 | 
					@ -1244,7 +1248,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
        const senderId = mEvent.getSender() ?? '';
 | 
					        const senderId = mEvent.getSender() ?? '';
 | 
				
			||||||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
					        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 (
 | 
					        return (
 | 
				
			||||||
          <Event
 | 
					          <Event
 | 
				
			||||||
| 
						 | 
					@ -1278,7 +1284,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
        const senderId = mEvent.getSender() ?? '';
 | 
					        const senderId = mEvent.getSender() ?? '';
 | 
				
			||||||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
					        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 (
 | 
					        return (
 | 
				
			||||||
          <Event
 | 
					          <Event
 | 
				
			||||||
| 
						 | 
					@ -1312,7 +1320,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
        const senderId = mEvent.getSender() ?? '';
 | 
					        const senderId = mEvent.getSender() ?? '';
 | 
				
			||||||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
					        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 (
 | 
					        return (
 | 
				
			||||||
          <Event
 | 
					          <Event
 | 
				
			||||||
| 
						 | 
					@ -1348,7 +1358,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
      const senderId = mEvent.getSender() ?? '';
 | 
					      const senderId = mEvent.getSender() ?? '';
 | 
				
			||||||
      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
					      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 (
 | 
					      return (
 | 
				
			||||||
        <Event
 | 
					        <Event
 | 
				
			||||||
| 
						 | 
					@ -1389,7 +1401,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
      const senderId = mEvent.getSender() ?? '';
 | 
					      const senderId = mEvent.getSender() ?? '';
 | 
				
			||||||
      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
					      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 (
 | 
					      return (
 | 
				
			||||||
        <Event
 | 
					        <Event
 | 
				
			||||||
| 
						 | 
					@ -1544,7 +1558,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
            <div
 | 
					            <div
 | 
				
			||||||
              style={{
 | 
					              style={{
 | 
				
			||||||
                padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
 | 
					                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>
 | 
					            </div>
 | 
				
			||||||
          )}
 | 
					          )}
 | 
				
			||||||
          {(canPaginateBack || !rangeAtStart) &&
 | 
					          {(canPaginateBack || !rangeAtStart) &&
 | 
				
			||||||
            (messageLayout === 1 ? (
 | 
					            (messageLayout === MessageLayout.Compact ? (
 | 
				
			||||||
              <>
 | 
					              <>
 | 
				
			||||||
                <MessageBase>
 | 
					                <MessageBase>
 | 
				
			||||||
                  <CompactPlaceholder key={getItems().length} />
 | 
					                  <CompactPlaceholder key={getItems().length} />
 | 
				
			||||||
| 
						 | 
					@ -1587,7 +1601,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 | 
				
			||||||
          {getItems().map(eventRenderer)}
 | 
					          {getItems().map(eventRenderer)}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          {(!liveTimelineLinked || !rangeAtEnd) &&
 | 
					          {(!liveTimelineLinked || !rangeAtEnd) &&
 | 
				
			||||||
            (messageLayout === 1 ? (
 | 
					            (messageLayout === MessageLayout.Compact ? (
 | 
				
			||||||
              <>
 | 
					              <>
 | 
				
			||||||
                <MessageBase ref={observeFrontAnchor}>
 | 
					                <MessageBase ref={observeFrontAnchor}>
 | 
				
			||||||
                  <CompactPlaceholder key={getItems().length} />
 | 
					                  <CompactPlaceholder key={getItems().length} />
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -716,7 +716,7 @@ export const Message = as<'div', MessageProps>(
 | 
				
			||||||
    const headerJSX = !collapse && (
 | 
					    const headerJSX = !collapse && (
 | 
				
			||||||
      <Box
 | 
					      <Box
 | 
				
			||||||
        gap="300"
 | 
					        gap="300"
 | 
				
			||||||
        direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
 | 
					        direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
 | 
				
			||||||
        justifyContent="SpaceBetween"
 | 
					        justifyContent="SpaceBetween"
 | 
				
			||||||
        alignItems="Baseline"
 | 
					        alignItems="Baseline"
 | 
				
			||||||
        grow="Yes"
 | 
					        grow="Yes"
 | 
				
			||||||
| 
						 | 
					@ -728,12 +728,12 @@ export const Message = as<'div', MessageProps>(
 | 
				
			||||||
          onContextMenu={onUserClick}
 | 
					          onContextMenu={onUserClick}
 | 
				
			||||||
          onClick={onUsernameClick}
 | 
					          onClick={onUsernameClick}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
 | 
					          <Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate>
 | 
				
			||||||
            <b>{senderDisplayName}</b>
 | 
					            <b>{senderDisplayName}</b>
 | 
				
			||||||
          </Text>
 | 
					          </Text>
 | 
				
			||||||
        </Username>
 | 
					        </Username>
 | 
				
			||||||
        <Box shrink="No" gap="100">
 | 
					        <Box shrink="No" gap="100">
 | 
				
			||||||
          {messageLayout === 0 && hover && (
 | 
					          {messageLayout === MessageLayout.Modern && hover && (
 | 
				
			||||||
            <>
 | 
					            <>
 | 
				
			||||||
              <Text as="span" size="T200" priority="300">
 | 
					              <Text as="span" size="T200" priority="300">
 | 
				
			||||||
                {senderId}
 | 
					                {senderId}
 | 
				
			||||||
| 
						 | 
					@ -743,12 +743,12 @@ export const Message = as<'div', MessageProps>(
 | 
				
			||||||
              </Text>
 | 
					              </Text>
 | 
				
			||||||
            </>
 | 
					            </>
 | 
				
			||||||
          )}
 | 
					          )}
 | 
				
			||||||
          <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
 | 
					          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
				
			||||||
        </Box>
 | 
					        </Box>
 | 
				
			||||||
      </Box>
 | 
					      </Box>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const avatarJSX = !collapse && messageLayout !== 1 && (
 | 
					    const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
 | 
				
			||||||
      <AvatarBase>
 | 
					      <AvatarBase>
 | 
				
			||||||
        <Avatar
 | 
					        <Avatar
 | 
				
			||||||
          className={css.MessageAvatar}
 | 
					          className={css.MessageAvatar}
 | 
				
			||||||
| 
						 | 
					@ -1043,18 +1043,18 @@ export const Message = as<'div', MessageProps>(
 | 
				
			||||||
            </Menu>
 | 
					            </Menu>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {messageLayout === 1 && (
 | 
					        {messageLayout === MessageLayout.Compact && (
 | 
				
			||||||
          <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
 | 
					          <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
 | 
				
			||||||
            {msgContentJSX}
 | 
					            {msgContentJSX}
 | 
				
			||||||
          </CompactLayout>
 | 
					          </CompactLayout>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {messageLayout === 2 && (
 | 
					        {messageLayout === MessageLayout.Bubble && (
 | 
				
			||||||
          <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
					          <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
				
			||||||
            {headerJSX}
 | 
					            {headerJSX}
 | 
				
			||||||
            {msgContentJSX}
 | 
					            {msgContentJSX}
 | 
				
			||||||
          </BubbleLayout>
 | 
					          </BubbleLayout>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {messageLayout !== 1 && messageLayout !== 2 && (
 | 
					        {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
 | 
				
			||||||
          <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
					          <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
 | 
				
			||||||
            {headerJSX}
 | 
					            {headerJSX}
 | 
				
			||||||
            {msgContentJSX}
 | 
					            {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 { flushSync } from 'react-dom';
 | 
				
			||||||
import { useAlive } from './useAlive';
 | 
					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 type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
					export const useAsync = <TData, TError, TArgs extends unknown[]>(
 | 
				
			||||||
  asyncCallback: AsyncCallback<TArgs, TData>
 | 
					  asyncCallback: AsyncCallback<TArgs, TData>,
 | 
				
			||||||
): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
 | 
					  onStateChange: (state: AsyncState<TData, TError>) => void
 | 
				
			||||||
  const [state, setState] = useState<AsyncState<TData, TError>>({
 | 
					): AsyncCallback<TArgs, TData> => {
 | 
				
			||||||
    status: AsyncStatus.Idle,
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
  const alive = useAlive();
 | 
					  const alive = useAlive();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Tracks the request number.
 | 
					  // Tracks the request number.
 | 
				
			||||||
| 
						 | 
					@ -53,7 +51,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
				
			||||||
        flushSync(() => {
 | 
					        flushSync(() => {
 | 
				
			||||||
          // flushSync because
 | 
					          // flushSync because
 | 
				
			||||||
          // https://github.com/facebook/react/issues/26713#issuecomment-1872085134
 | 
					          // https://github.com/facebook/react/issues/26713#issuecomment-1872085134
 | 
				
			||||||
          setState({
 | 
					          onStateChange({
 | 
				
			||||||
            status: AsyncStatus.Loading,
 | 
					            status: AsyncStatus.Loading,
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
| 
						 | 
					@ -69,7 +67,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        if (alive()) {
 | 
					        if (alive()) {
 | 
				
			||||||
          queueMicrotask(() => {
 | 
					          queueMicrotask(() => {
 | 
				
			||||||
            setState({
 | 
					            onStateChange({
 | 
				
			||||||
              status: AsyncStatus.Success,
 | 
					              status: AsyncStatus.Success,
 | 
				
			||||||
              data,
 | 
					              data,
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
| 
						 | 
					@ -83,7 +81,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (alive()) {
 | 
					        if (alive()) {
 | 
				
			||||||
          queueMicrotask(() => {
 | 
					          queueMicrotask(() => {
 | 
				
			||||||
            setState({
 | 
					            onStateChange({
 | 
				
			||||||
              status: AsyncStatus.Error,
 | 
					              status: AsyncStatus.Error,
 | 
				
			||||||
              error: e as TError,
 | 
					              error: e as TError,
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
| 
						 | 
					@ -92,8 +90,32 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
 | 
				
			||||||
        throw e;
 | 
					        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];
 | 
					  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 { useEffect, useCallback, useMemo } from 'react';
 | 
				
			||||||
import { useState, useEffect } from 'react';
 | 
					import { IMyDevice } from 'matrix-js-sdk';
 | 
				
			||||||
import { CryptoEvent, IMyDevice } from 'matrix-js-sdk';
 | 
					import { useQuery } from '@tanstack/react-query';
 | 
				
			||||||
import { CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
 | 
					import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
 | 
				
			||||||
import { useMatrixClient } from './useMatrixClient';
 | 
					import { useMatrixClient } from './useMatrixClient';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useDeviceList() {
 | 
					export const useDeviceListChange = (
 | 
				
			||||||
 | 
					  onChange: CryptoEventHandlerMap[CryptoEvent.DevicesUpdated]
 | 
				
			||||||
 | 
					) => {
 | 
				
			||||||
  const mx = useMatrixClient();
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  const [deviceList, setDeviceList] = useState<IMyDevice[] | null>(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    let isMounted = true;
 | 
					    mx.on(CryptoEvent.DevicesUpdated, onChange);
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const updateDevices = () =>
 | 
					 | 
				
			||||||
      mx.getDevices().then((data) => {
 | 
					 | 
				
			||||||
        if (!isMounted) return;
 | 
					 | 
				
			||||||
        setDeviceList(data.devices || []);
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    updateDevices();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const handleDevicesUpdate: CryptoEventHandlerMap[CryptoEvent.DevicesUpdated] = (users) => {
 | 
					 | 
				
			||||||
      const userId = mx.getUserId();
 | 
					 | 
				
			||||||
      if (userId && users.includes(userId)) {
 | 
					 | 
				
			||||||
        updateDevices();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    mx.on(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
 | 
					 | 
				
			||||||
    return () => {
 | 
					    return () => {
 | 
				
			||||||
      mx.removeListener(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
 | 
					      mx.removeListener(CryptoEvent.DevicesUpdated, onChange);
 | 
				
			||||||
      isMounted = false;
 | 
					 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					  }, [mx, onChange]);
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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]);
 | 
					  }, [mx]);
 | 
				
			||||||
  return deviceList;
 | 
					
 | 
				
			||||||
 | 
					  const { data: deviceList, refetch } = useQuery({
 | 
				
			||||||
 | 
					    queryKey: DEVICES_QUERY_KEY,
 | 
				
			||||||
 | 
					    queryFn: fetchDevices,
 | 
				
			||||||
 | 
					    staleTime: 0,
 | 
				
			||||||
 | 
					    gcTime: Infinity,
 | 
				
			||||||
 | 
					    refetchOnMount: 'always',
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const refreshDeviceList = useCallback(async () => {
 | 
				
			||||||
 | 
					    await refetch();
 | 
				
			||||||
 | 
					  }, [refetch]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useDeviceListChange(
 | 
				
			||||||
 | 
					    useCallback(
 | 
				
			||||||
 | 
					      (users) => {
 | 
				
			||||||
 | 
					        const userId = mx.getUserId();
 | 
				
			||||||
 | 
					        if (userId && users.includes(userId)) {
 | 
				
			||||||
 | 
					          refreshDeviceList();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      [mx, refreshDeviceList]
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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 { Room } from 'matrix-js-sdk';
 | 
				
			||||||
import { useEffect, useMemo } from 'react';
 | 
					import { useCallback, useMemo, useState } from 'react';
 | 
				
			||||||
import { getRelevantPacks, ImagePack, PackUsage } from '../plugins/custom-emoji';
 | 
					 | 
				
			||||||
import { AccountDataEvent } from '../../types/matrix/accountData';
 | 
					import { AccountDataEvent } from '../../types/matrix/accountData';
 | 
				
			||||||
import { StateEvent } from '../../types/matrix/room';
 | 
					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 = (
 | 
					export const useUserImagePack = (): ImagePack | undefined => {
 | 
				
			||||||
  mx: MatrixClient,
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
  usage: PackUsage,
 | 
					  const [userPack, setUserPack] = useState(() => getUserImagePack(mx));
 | 
				
			||||||
  rooms: Room[]
 | 
					 | 
				
			||||||
): ImagePack[] => {
 | 
					 | 
				
			||||||
  const [forceCount, forceUpdate] = useForceUpdate();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const relevantPacks = useMemo(
 | 
					  useAccountDataCallback(
 | 
				
			||||||
    () => getRelevantPacks(mx, rooms).filter((pack) => pack.getImagesFor(usage).length > 0),
 | 
					    mx,
 | 
				
			||||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
					    useCallback(
 | 
				
			||||||
    [mx, usage, rooms, forceCount]
 | 
					      (mEvent) => {
 | 
				
			||||||
 | 
					        if (mEvent.getType() === AccountDataEvent.PoniesUserEmotes) {
 | 
				
			||||||
 | 
					          setUserPack(getUserImagePack(mx));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      [mx]
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  return userPack;
 | 
				
			||||||
    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();
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mx.on(ClientEvent.AccountData, handleUpdate);
 | 
					export const useGlobalImagePacks = (): ImagePack[] => {
 | 
				
			||||||
    mx.on(RoomStateEvent.Events, handleUpdate);
 | 
					  const mx = useMatrixClient();
 | 
				
			||||||
    return () => {
 | 
					  const [globalPacks, setGlobalPacks] = useState(() => getGlobalImagePacks(mx));
 | 
				
			||||||
      mx.removeListener(ClientEvent.AccountData, handleUpdate);
 | 
					
 | 
				
			||||||
      mx.removeListener(RoomStateEvent.Events, handleUpdate);
 | 
					  useAccountDataCallback(
 | 
				
			||||||
    };
 | 
					    mx,
 | 
				
			||||||
  }, [mx, rooms, forceUpdate]);
 | 
					    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;
 | 
					  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