redesigned app settings and switch to rust crypto ()

* 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 ()

* 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:
Ajay Bura 2025-02-10 16:49:47 +11:00 committed by GitHub
parent f5d68fcc22
commit 56b754153a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
196 changed files with 14171 additions and 8403 deletions
package-lock.jsonpackage.json
src/app
components
features
hooks

7387
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -44,7 +44,7 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "2.0.0",
"folds": "2.1.0",
"formik": "2.4.6",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
@ -56,7 +56,7 @@
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "34.11.1",
"matrix-js-sdk": "35.0.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.29.0",
@ -108,6 +108,6 @@
"vite": "5.0.13",
"vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.1"
"vite-plugin-top-level-await": "1.4.4"
}
}

View 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);
}

View 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;
}

View file

@ -19,7 +19,7 @@ export function CapabilitiesAndMediaConfigLoader({
[]
>(
useCallback(async () => {
const result = await Promise.allSettled([mx.getCapabilities(true), mx.getMediaConfig()]);
const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
return [capabilities, mediaConfig];

View file

@ -9,7 +9,7 @@ type CapabilitiesLoaderProps = {
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
const mx = useMatrixClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx]));
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
useEffect(() => {
load();

View 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} />;
}

View 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>
);
}
);

View 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);
}

View 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">Youre 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>
);
}
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -13,7 +13,6 @@ import {
IconButton,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
export type UIAFlowOverlayProps = {
currentStep: number;
@ -29,7 +28,7 @@ export function UIAFlowOverlay({
}: UIAFlowOverlayProps) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: stopPropagation }}>
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
<Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
{children}

View file

@ -16,14 +16,14 @@ import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji';
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
type EmoticonSearchItem = ExtendedPackImage | IEmoji;
type EmoticonSearchItem = PackImageReader | IEmoji;
type EmoticonAutocompleteProps = {
imagePackRooms: Room[];
@ -52,21 +52,21 @@ export function EmoticonAutocomplete({
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms);
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20);
const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = [];
return list
.concat(
imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)),
emojis
)
return list.concat(
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
emojis
);
}, [imagePacks]);
const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
const autoCompleteEmoticon = (result ? result.items : recentEmoji)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode));
const autoCompleteEmoticon = (result ? result.items : recentEmoji).sort((a, b) =>
a.shortcode.localeCompare(b.shortcode)
);
useEffect(() => {
if (query.text) search(query.text);

View file

@ -41,7 +41,6 @@ import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
@ -50,6 +49,7 @@ import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji';
import { mobileOrTablet } from '../../utils/user-agent';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
@ -359,16 +359,16 @@ function ImagePackSidebarStack({
}: {
mx: MatrixClient;
packs: ImagePack[];
usage: PackUsage;
usage: ImageUsage;
onItemClick: (id: string) => void;
useAuthentication?: boolean;
}) {
const activeGroupId = useAtomValue(activeGroupIdAtom);
return (
<SidebarStack>
{usage === PackUsage.Emoticon && <SidebarDivider />}
{usage === ImageUsage.Emoticon && <SidebarDivider />}
{packs.map((pack) => {
let label = pack.displayName;
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
return (
<SidebarBtn
@ -384,7 +384,10 @@ function ImagePackSidebarStack({
height: toRem(24),
objectFit: 'contain',
}}
src={mxcUrlToHttp(mx, pack.getPackAvatarUrl(usage) ?? '', useAuthentication) || pack.avatarUrl}
src={
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ||
pack.meta.avatar
}
alt={label || 'Unknown Pack'}
/>
</SidebarBtn>
@ -462,130 +465,156 @@ export function SearchEmojiGroup({
tab: EmojiBoardTab;
label: string;
id: string;
emojis: Array<ExtendedPackImage | IEmoji>;
emojis: Array<PackImageReader | IEmoji>;
useAuthentication?: boolean;
}) {
return (
<EmojiGroup key={id} id={id} label={label}>
{tab === EmojiBoardTab.Emoji
? searchResult.sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((emoji) =>
'unicode' in emoji ? (
<EmojiItem
key={emoji.unicode}
label={emoji.label}
type={EmojiType.Emoji}
data={emoji.unicode}
shortcode={emoji.shortcode}
>
{emoji.unicode}
</EmojiItem>
) : (
<EmojiItem
key={emoji.shortcode}
label={emoji.body || emoji.shortcode}
type={EmojiType.CustomEmoji}
data={emoji.url}
shortcode={emoji.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
/>
</EmojiItem>
)
)
? searchResult
.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
.map((emoji) =>
'unicode' in emoji ? (
<EmojiItem
key={emoji.unicode}
label={emoji.label}
type={EmojiType.Emoji}
data={emoji.unicode}
shortcode={emoji.shortcode}
>
{emoji.unicode}
</EmojiItem>
) : (
<EmojiItem
key={emoji.shortcode}
label={emoji.body || emoji.shortcode}
type={EmojiType.CustomEmoji}
data={emoji.url}
shortcode={emoji.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
/>
</EmojiItem>
)
)
: searchResult.map((emoji) =>
'unicode' in emoji ? null : (
<StickerItem
key={emoji.shortcode}
label={emoji.body || emoji.shortcode}
type={EmojiType.Sticker}
data={emoji.url}
shortcode={emoji.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
/>
</StickerItem>
)
)}
'unicode' in emoji ? null : (
<StickerItem
key={emoji.shortcode}
label={emoji.body || emoji.shortcode}
type={EmojiType.Sticker}
data={emoji.url}
shortcode={emoji.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
/>
</StickerItem>
)
)}
</EmojiGroup>
);
}
export const CustomEmojiGroups = memo(
({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => (
({
mx,
groups,
useAuthentication,
}: {
mx: MatrixClient;
groups: ImagePack[];
useAuthentication?: boolean;
}) => (
<>
{groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
{pack.getEmojis().sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((image) => (
<EmojiItem
key={image.shortcode}
label={image.body || image.shortcode}
type={EmojiType.CustomEmoji}
data={image.url}
shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</EmojiItem>
))}
<EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
{pack
.getImages(ImageUsage.Emoticon)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
.map((image) => (
<EmojiItem
key={image.shortcode}
label={image.body || image.shortcode}
type={EmojiType.CustomEmoji}
data={image.url}
shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</EmojiItem>
))}
</EmojiGroup>
))}
</>
)
);
export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => (
<>
{groups.length === 0 && (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
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>
export const StickerGroups = memo(
({
mx,
groups,
useAuthentication,
}: {
mx: MatrixClient;
groups: ImagePack[];
useAuthentication?: boolean;
}) => (
<>
{groups.length === 0 && (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
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>
)}
{groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
{pack.getStickers().sort((a, b) => a.shortcode.localeCompare(b.shortcode)).map((image) => (
<StickerItem
key={image.shortcode}
label={image.body || image.shortcode}
type={EmojiType.Sticker}
data={image.url}
shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</StickerItem>
))}
</EmojiGroup>
))}
</>
));
)}
{groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
{pack
.getImages(ImageUsage.Sticker)
.sort((a, b) => a.shortcode.localeCompare(b.shortcode))
.map((image) => (
<StickerItem
key={image.shortcode}
label={image.body || image.shortcode}
type={EmojiType.Sticker}
data={image.url}
shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</StickerItem>
))}
</EmojiGroup>
))}
</>
)
);
export const NativeEmojiGroups = memo(
({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
@ -609,7 +638,7 @@ export const NativeEmojiGroups = memo(
)
);
const getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => {
const getSearchListItemStr = (item: PackImageReader | IEmoji) => {
const shortcode = `:${item.shortcode}:`;
if ('body' in item) {
return [shortcode, item.body ?? ''];
@ -646,14 +675,14 @@ export function EmojiBoard({
}) {
const emojiTab = tab === EmojiBoardTab.Emoji;
const stickerTab = tab === EmojiBoardTab.Sticker;
const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const emojiGroupLabels = useEmojiGroupLabels();
const emojiGroupIcons = useEmojiGroupIcons();
const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms);
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
const recentEmojis = useRecentEmoji(mx, 21);
const contentScrollRef = useRef<HTMLDivElement>(null);
@ -661,8 +690,8 @@ export function EmojiBoard({
const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
const searchList = useMemo(() => {
let list: Array<ExtendedPackImage | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage)));
let list: Array<PackImageReader | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
if (emojiTab) list = list.concat(emojis);
return list;
}, [emojiTab, usage, imagePacks]);
@ -688,7 +717,7 @@ export function EmojiBoard({
const syncActiveGroupId = useCallback(() => {
const targetEl = contentScrollRef.current;
if (!targetEl) return;
const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[];
const groupEls = Array.from(targetEl.querySelectorAll('div[data-group-id]')) as HTMLElement[];
const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
setActiveGroupId(groupId);
@ -735,7 +764,10 @@ export function EmojiBoard({
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
const img = document.createElement('img');
img.className = css.CustomEmojiImg;
img.setAttribute('src', mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data);
img.setAttribute(
'src',
mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data
);
img.setAttribute('alt', emojiInfo.shortcode);
emojiPreviewRef.current.textContent = '';
emojiPreviewRef.current.appendChild(img);
@ -903,8 +935,16 @@ export function EmojiBoard({
{emojiTab && recentEmojis.length > 0 && (
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
)}
{emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />}
{stickerTab && <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />}
{emojiTab && (
<CustomEmojiGroups
mx={mx}
groups={imagePacks}
useAuthentication={useAuthentication}
/>
)}
{stickerTab && (
<StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />
)}
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
</Box>
</Scroll>

View 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',
});

View 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>
);
}
);

View file

@ -0,0 +1 @@
export * from './ImageEditor';

View 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>
);
}
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />
);
}

View 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>
}
/>
</>
);
}

View 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} />;
}

View file

@ -0,0 +1 @@
export * from './ImagePackView';

View 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,
});

View 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>
);
}

View file

@ -0,0 +1 @@
export * from './InfoCard';

View 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,
},
]);

View file

@ -1,6 +1,7 @@
import { Box, Icon, IconSrc } from 'folds';
import React, { ReactNode } from 'react';
import { CompactLayout, ModernLayout } from '..';
import { MessageLayout } from '../../../state/settings';
export type EventContentProps = {
messageLayout: number;
@ -11,9 +12,9 @@ export type EventContentProps = {
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
const beforeJSX = (
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
{messageLayout === 1 && time}
{messageLayout === MessageLayout.Compact && time}
<Box
grow={messageLayout === 1 ? undefined : 'Yes'}
grow={messageLayout === MessageLayout.Compact ? undefined : 'Yes'}
alignItems="Center"
justifyContent="Center"
>
@ -25,11 +26,11 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
const msgContentJSX = (
<Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
{content}
{messageLayout !== 1 && time}
{messageLayout !== MessageLayout.Compact && time}
</Box>
);
return messageLayout === 1 ? (
return messageLayout === MessageLayout.Compact ? (
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
) : (
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>

View file

@ -27,7 +27,6 @@ import {
getFileNameExt,
mimeTypeToExt,
} from '../../../utils/mimeTypes';
import * as css from './style.css';
import { stopPropagation } from '../../../utils/keyboard';
import {
decryptFile,
@ -36,6 +35,7 @@ import {
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
const renderErrorButton = (retry: () => void, text: string) => (
<TooltipProvider
@ -111,7 +111,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
}}
>
<Modal
className={css.ModalWide}
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
@ -199,7 +199,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
}}
>
<Modal
className={css.ModalWide}
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>

View file

@ -28,6 +28,7 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
type RenderViewerProps = {
src: string;
@ -121,7 +122,7 @@ export const ImageContent = as<'div', ImageContentProps>(
}}
>
<Modal
className={css.ModalWide}
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>

View file

@ -30,8 +30,3 @@ export const AbsoluteFooter = style([
right: config.space.S100,
},
]);
export const ModalWide = style({
minWidth: '85vw',
minHeight: '90vh',
});

View file

@ -27,14 +27,14 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = {
children: ReactNode;
};
export function PageNav({ children }: ClientDrawerLayoutProps) {
export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
return (
<Box
grow={isMobile ? 'Yes' : undefined}
className={css.PageNav}
className={css.PageNav({ size })}
shrink={isMobile ? 'Yes' : 'No'}
>
<Box grow="Yes" direction="Column">
@ -44,15 +44,17 @@ export function PageNav({ children }: ClientDrawerLayoutProps) {
);
}
export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => (
<Header
className={classNames(css.PageNavHeader, className)}
variant="Background"
size="600"
{...props}
ref={ref}
/>
));
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
({ className, outlined, ...props }, ref) => (
<Header
className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background"
size="600"
{...props}
ref={ref}
/>
)
);
export function PageNavContent({
scrollRef,
@ -88,11 +90,11 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
));
export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, balance, ...props }, ref) => (
({ className, outlined, balance, ...props }, ref) => (
<Header
as="header"
size="600"
className={classNames(css.PageHeader({ balance }), className)}
className={classNames(css.PageHeader({ balance, outlined }), className)}
{...props}
ref={ref}
/>

View file

@ -2,30 +2,55 @@ import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const PageNav = style({
width: toRem(256),
});
export const PageNavHeader = style({
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
flexShrink: 0,
borderBottomWidth: 1,
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,
export const PageNav = recipe({
variants: {
size: {
'400': {
width: toRem(256),
},
'300': {
width: toRem(222),
},
},
},
defaultVariants: {
size: '400',
},
});
export type PageNavVariants = RecipeVariants<typeof PageNav>;
export const PageNavHeader = 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({
minHeight: '100%',
@ -38,7 +63,6 @@ export const PageHeader = recipe({
base: {
paddingLeft: config.space.S400,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
},
variants: {
balance: {
@ -46,6 +70,14 @@ export const PageHeader = recipe({
paddingLeft: config.space.S200,
},
},
outlined: {
true: {
borderBottomWidth: config.borderWidth.B300,
},
},
},
defaultVariants: {
outlined: true,
},
});
export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;

View file

@ -6,7 +6,7 @@ type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> &
size: '400' | '500';
};
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ variant, size, style, after, ...props }, ref) => {
({ variant = 'Background', size, style, after, ...props }, ref) => {
const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
return (

View file

@ -0,0 +1 @@
export * from './PasswordInput';

View 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>
);
}

View file

@ -0,0 +1 @@
export * from './SettingTile';

View 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>
);
}

View 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>
);
}

View file

@ -1,6 +1,8 @@
export * from './types';
export * from './DummyStage';
export * from './EmailStage';
export * from './PasswordStage';
export * from './ReCaptchaStage';
export * from './RegistrationTokenStage';
export * from './SSOStage';
export * from './TermsStage';

View 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>
);
}

View file

@ -7,9 +7,21 @@ export const UploadCard = recipe({
padding: config.space.S300,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderColor: color.SurfaceVariant.ContainerLine,
},
variants: {
radii: RadiiVariant,
outlined: {
true: {
borderStyle: 'solid',
borderWidth: config.borderWidth.B300,
},
},
compact: {
true: {
padding: config.space.S100,
},
},
},
defaultVariants: {
radii: '400',

View file

@ -12,8 +12,13 @@ type UploadCardProps = {
};
export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>(
({ before, after, children, bottom, radii }, ref) => (
<Box className={css.UploadCard({ radii })} direction="Column" gap="200" ref={ref}>
({ before, after, children, bottom, radii, outlined, compact }, ref) => (
<Box
className={css.UploadCard({ radii, outlined, compact })}
direction="Column"
gap="200"
ref={ref}
>
<Box alignItems="Center" gap="200">
{before}
<Box alignItems="Center" grow="Yes" gap="200">
@ -33,7 +38,7 @@ type UploadCardProgressProps = {
export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
return (
<Box direction="Column" gap="200">
<Box grow="Yes" direction="Column" gap="200">
<ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
<Box alignItems="Center" justifyContent="SpaceBetween">
<Badge variant="Secondary" fill="Solid" radii="Pill">
@ -49,6 +54,24 @@ export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgress
);
}
export function CompactUploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="Pill">
<Text size="L400">{`${Math.round(percent(0, totalBytes, sentBytes))}%`}</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
</Box>
<Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">
{bytesToSize(sentBytes)} / {bytesToSize(totalBytes)}
</Text>
</Badge>
</Box>
);
}
type UploadCardErrorProps = {
children: ReactNode;
};

View file

@ -1,30 +1,26 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { TUploadAtom, UploadStatus, useBindUploadAtom } from '../../state/upload';
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { getFileTypeIcon } from '../../utils/common';
type UploadCardRendererProps = {
file: TUploadContent;
isEncrypted?: boolean;
uploadAtom: TUploadAtom;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
};
export function UploadCardRenderer({
file,
isEncrypted,
uploadAtom,
onRemove,
onComplete,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
const { upload, startUpload, cancelUpload } = useBindUploadAtom(
mx,
file,
uploadAtom,
isEncrypted
);
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
if (upload.status === UploadStatus.Idle) startUpload();
@ -33,6 +29,12 @@ export function UploadCardRenderer({
onRemove(file);
};
useEffect(() => {
if (upload.status === UploadStatus.Success) {
onComplete?.(upload);
}
}, [upload, onComplete]);
return (
<UploadCard
radii="300"

View file

@ -1,2 +1,3 @@
export * from './UploadCard';
export * from './UploadCardRenderer';
export * from './CompactUploadCardRenderer';

View file

@ -3,7 +3,8 @@ import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { useSpace } from '../../hooks/useSpace';
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
@ -258,7 +259,7 @@ export function Lobby() {
const joinRuleContent = getStateEvent(
itemRoom,
StateEvent.RoomJoinRules
)?.getContent<IJoinRuleEventContent>();
)?.getContent<RoomJoinRulesEventContent>();
if (joinRuleContent) {
const allow =

View file

@ -56,7 +56,13 @@ import {
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import {
TUploadContent,
encryptFile,
getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp,
} from '../../utils/matrix';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
@ -157,7 +163,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (mx.isRoomEncrypted(roomId)) {
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
@ -172,7 +178,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
item: fileItems,
});
},
[setSelectedFiles, roomId, mx]
[setSelectedFiles, room]
);
const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles);
@ -413,7 +419,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key
key={index}
file={fileItem.file}
isEncrypted={!!fileItem.encInfo}
uploadAtom={roomUploadAtomFamily(fileItem.file)}
onRemove={handleRemoveUpload}

View file

@ -85,7 +85,7 @@ import {
reactionOrEditEvent,
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { openProfileViewer } from '../../../client/action/navigation';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
@ -336,7 +336,10 @@ const useTimelinePagination = (
backwards ? Direction.Backward : Direction.Forward
) ?? timelineToPaginate;
// Decrypt all event ahead of render cycle
if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
const roomId = fetchedTimeline.getRoomId();
const room = roomId ? mx.getRoom(roomId) : null;
if (room?.hasEncryptionStateEvent()) {
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
}
@ -421,7 +424,6 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const encryptedRoom = mx.isRoomEncrypted(room.roomId);
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
@ -429,7 +431,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const powerLevels = usePowerLevelsContext();
@ -1030,7 +1032,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === 2}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)}
</Message>
@ -1126,7 +1128,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === 2}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
);
}
@ -1211,7 +1213,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const highlighted = focusItem?.index === item && focusItem.highlight;
const parsed = parseMemberEvent(mEvent);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1244,7 +1248,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1278,7 +1284,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1312,7 +1320,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1348,7 +1358,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1389,7 +1401,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return (
<Event
@ -1544,7 +1558,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<div
style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === 1 ? config.space.S400 : toRem(64)
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`,
}}
>
@ -1552,7 +1566,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</div>
)}
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === 1 ? (
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
@ -1587,7 +1601,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === 1 ? (
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />

View file

@ -716,7 +716,7 @@ export const Message = as<'div', MessageProps>(
const headerJSX = !collapse && (
<Box
gap="300"
direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween"
alignItems="Baseline"
grow="Yes"
@ -728,12 +728,12 @@ export const Message = as<'div', MessageProps>(
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
<Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate>
<b>{senderDisplayName}</b>
</Text>
</Username>
<Box shrink="No" gap="100">
{messageLayout === 0 && hover && (
{messageLayout === MessageLayout.Modern && hover && (
<>
<Text as="span" size="T200" priority="300">
{senderId}
@ -743,12 +743,12 @@ export const Message = as<'div', MessageProps>(
</Text>
</>
)}
<Time ts={mEvent.getTs()} compact={messageLayout === 1} />
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
</Box>
</Box>
);
const avatarJSX = !collapse && messageLayout !== 1 && (
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase>
<Avatar
className={css.MessageAvatar}
@ -1043,18 +1043,18 @@ export const Message = as<'div', MessageProps>(
</Menu>
</div>
)}
{messageLayout === 1 && (
{messageLayout === MessageLayout.Compact && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</CompactLayout>
)}
{messageLayout === 2 && (
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
{msgContentJSX}
</BubbleLayout>
)}
{messageLayout !== 1 && messageLayout !== 2 && (
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
{msgContentJSX}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1 @@
export * from './About';

View 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>
);
}

View file

@ -0,0 +1 @@
export * from './Account';

View 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>
);
}
);

View 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>
);
}

View file

@ -0,0 +1 @@
export * from './DevelopTools';

View 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',
});

View 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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>&quot;Verify Manually&quot;</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>
)}
</>
);
}

View file

@ -0,0 +1 @@
export * from './Devices';

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View file

@ -0,0 +1 @@
export * from './EmojisStickers';

View 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>
);
}

View file

@ -0,0 +1 @@
export * from './General';

View file

@ -0,0 +1 @@
export * from './Settings';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1 @@
export * from './Notifications';

View file

@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const SequenceCardStyle = style({
padding: config.space.S300,
});

View file

@ -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;
}

View 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;
}

View file

@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useAlive } from './useAlive';
@ -31,12 +31,10 @@ export type AsyncState<D, E = unknown> = AsyncIdle | AsyncLoading | AsyncSuccess
export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
asyncCallback: AsyncCallback<TArgs, TData>
): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
const [state, setState] = useState<AsyncState<TData, TError>>({
status: AsyncStatus.Idle,
});
export const useAsync = <TData, TError, TArgs extends unknown[]>(
asyncCallback: AsyncCallback<TArgs, TData>,
onStateChange: (state: AsyncState<TData, TError>) => void
): AsyncCallback<TArgs, TData> => {
const alive = useAlive();
// Tracks the request number.
@ -53,7 +51,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
flushSync(() => {
// flushSync because
// https://github.com/facebook/react/issues/26713#issuecomment-1872085134
setState({
onStateChange({
status: AsyncStatus.Loading,
});
});
@ -69,7 +67,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
}
if (alive()) {
queueMicrotask(() => {
setState({
onStateChange({
status: AsyncStatus.Success,
data,
});
@ -83,7 +81,7 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
if (alive()) {
queueMicrotask(() => {
setState({
onStateChange({
status: AsyncStatus.Error,
error: e as TError,
});
@ -92,8 +90,32 @@ export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
throw e;
}
},
[asyncCallback, alive]
[asyncCallback, alive, onStateChange]
);
return callback;
};
export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
asyncCallback: AsyncCallback<TArgs, TData>
): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
const [state, setState] = useState<AsyncState<TData, TError>>({
status: AsyncStatus.Idle,
});
const callback = useAsync(asyncCallback, setState);
return [state, callback];
};
export const useAsyncCallbackValue = <TData, TError>(
asyncCallback: AsyncCallback<[], TData>
): [AsyncState<TData, TError>, AsyncCallback<[], TData>] => {
const [state, load] = useAsyncCallback<TData, TError, []>(asyncCallback);
useEffect(() => {
load();
}, [load]);
return [state, load];
};

View 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;
};

View file

@ -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;
}

View file

@ -1,35 +1,77 @@
/* eslint-disable import/prefer-default-export */
import { useState, useEffect } from 'react';
import { CryptoEvent, IMyDevice } from 'matrix-js-sdk';
import { CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
import { useEffect, useCallback, useMemo } from 'react';
import { IMyDevice } from 'matrix-js-sdk';
import { useQuery } from '@tanstack/react-query';
import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
import { useMatrixClient } from './useMatrixClient';
export function useDeviceList() {
export const useDeviceListChange = (
onChange: CryptoEventHandlerMap[CryptoEvent.DevicesUpdated]
) => {
const mx = useMatrixClient();
const [deviceList, setDeviceList] = useState<IMyDevice[] | null>(null);
useEffect(() => {
let isMounted = true;
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);
mx.on(CryptoEvent.DevicesUpdated, onChange);
return () => {
mx.removeListener(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
isMounted = false;
mx.removeListener(CryptoEvent.DevicesUpdated, onChange);
};
}, [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]);
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];
};

View 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;
};

View file

@ -1,48 +1,161 @@
import { ClientEvent, MatrixClient, MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useEffect, useMemo } from 'react';
import { getRelevantPacks, ImagePack, PackUsage } from '../plugins/custom-emoji';
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo, useState } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { StateEvent } from '../../types/matrix/room';
import { useForceUpdate } from './useForceUpdate';
import {
getGlobalImagePacks,
getRoomImagePack,
getRoomImagePacks,
getUserImagePack,
ImagePack,
ImageUsage,
} from '../plugins/custom-emoji';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { useStateEventCallback } from './useStateEventCallback';
export const useRelevantImagePacks = (
mx: MatrixClient,
usage: PackUsage,
rooms: Room[]
): ImagePack[] => {
const [forceCount, forceUpdate] = useForceUpdate();
export const useUserImagePack = (): ImagePack | undefined => {
const mx = useMatrixClient();
const [userPack, setUserPack] = useState(() => getUserImagePack(mx));
const relevantPacks = useMemo(
() => getRelevantPacks(mx, rooms).filter((pack) => pack.getImagesFor(usage).length > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[mx, usage, rooms, forceCount]
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.PoniesUserEmotes) {
setUserPack(getUserImagePack(mx));
}
},
[mx]
)
);
useEffect(() => {
const handleUpdate = (event: MatrixEvent) => {
if (
event.getType() === AccountDataEvent.PoniesEmoteRooms ||
event.getType() === AccountDataEvent.PoniesUserEmotes
) {
forceUpdate();
}
const eventRoomId = event.getRoomId();
if (
eventRoomId &&
event.getType() === StateEvent.PoniesRoomEmotes &&
rooms.find((room) => room.roomId === eventRoomId)
) {
forceUpdate();
}
};
return userPack;
};
mx.on(ClientEvent.AccountData, handleUpdate);
mx.on(RoomStateEvent.Events, handleUpdate);
return () => {
mx.removeListener(ClientEvent.AccountData, handleUpdate);
mx.removeListener(RoomStateEvent.Events, handleUpdate);
};
}, [mx, rooms, forceUpdate]);
export const useGlobalImagePacks = (): ImagePack[] => {
const mx = useMatrixClient();
const [globalPacks, setGlobalPacks] = useState(() => getGlobalImagePacks(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.PoniesEmoteRooms) {
setGlobalPacks(getGlobalImagePacks(mx));
}
},
[mx]
)
);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
const eventType = mEvent.getType();
const roomId = mEvent.getRoomId();
const stateKey = mEvent.getStateKey();
if (eventType === StateEvent.PoniesRoomEmotes && roomId && typeof stateKey === 'string') {
const global = !!globalPacks.find(
(pack) =>
pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey
);
if (global) {
setGlobalPacks(getGlobalImagePacks(mx));
}
}
},
[mx, globalPacks]
)
);
return globalPacks;
};
export const useRoomImagePack = (room: Room, stateKey: string): ImagePack | undefined => {
const mx = useMatrixClient();
const [roomPack, setRoomPack] = useState(() => getRoomImagePack(room, stateKey));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.PoniesRoomEmotes &&
mEvent.getStateKey() === stateKey
) {
setRoomPack(getRoomImagePack(room, stateKey));
}
},
[room, stateKey]
)
);
return roomPack;
};
export const useRoomImagePacks = (room: Room): ImagePack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => getRoomImagePacks(room));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.PoniesRoomEmotes
) {
setRoomPacks(getRoomImagePacks(room));
}
},
[room]
)
);
return roomPacks;
};
export const useRoomsImagePacks = (rooms: Room[]) => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomImagePacks));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
mEvent.getType() === StateEvent.PoniesRoomEmotes
) {
setRoomPacks(rooms.flatMap(getRoomImagePacks));
}
},
[rooms]
)
);
return roomPacks;
};
export const useRelevantImagePacks = (usage: ImageUsage, rooms: Room[]): ImagePack[] => {
const userPack = useUserImagePack();
const globalPacks = useGlobalImagePacks();
const roomsPacks = useRoomsImagePacks(rooms);
const relevantPacks = useMemo(() => {
const packs = userPack ? [userPack] : [];
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
const relPacks = packs.concat(
globalPacks,
roomsPacks.filter((pack) => !globalPackIds.has(pack.id))
);
return relPacks.filter((pack) => pack.getImages(usage).length > 0);
}, [userPack, globalPacks, roomsPacks, usage]);
return relevantPacks;
};

View 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;
};

View 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',
},
],
[]
);

View 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',
},
],
[]
);

View 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;
};

View 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;
};

View 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]);

View 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])
);
};

View 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;
};

View 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