redesigned app settings and switch to rust crypto (#1988)

* rework general settings

* account settings - WIP

* add missing key prop

* add object url hook

* extract wide modal styles

* profile settings and image editor - WIP

* add outline style to upload card

* remove file param from bind upload atom hook

* add compact variant to upload card

* add  compact upload card renderer

* add option to update profile avatar

* add option to change profile displayname

* allow displayname change based on capabilities check

* rearrange settings components into folders

* add system notification settings

* add initial page param in settings

* convert account data hook to typescript

* add push rule hook

* add notification mode hook

* add notification mode switcher component

* add all messages notification settings options

* add special messages notification settings

* add keyword notifications

* add ignored users section

* improve ignore user list strings

* add about settings

* add access token option in about settings

* add developer tools settings

* add expand button to account data dev tool option

* update folds

* fix editable active element textarea check

* do not close dialog when editable element in focus

* add text area plugins

* add text area intent handler hook

* add newline intent mod in text area

* add next line hotkey in text area intent hook

* add syntax error position dom utility function

* add account data editor

* add button to send new account data in dev tools

* improve custom emoji plugin

* add more custom emojis hooks

* add text util css

* add word break in setting tile title and description

* emojis and sticker user settings - WIP

* view image packs from settings

* emoji pack editing - WIP

* add option to edit pack meta

* change saved changes message

* add image edit and delete controls

* add option to upload pack images and apply changes

* fix state event type when updating image pack

* lazy load pack image tile img

* hide upload image button when user can not edit pack

* add option to add or remove global image packs

* upgrade to rust crypto (#2168)

* update matrix js sdk

* remove dead code

* use rust crypto

* update setPowerLevel usage

* fix types

* fix deprecated isRoomEncrypted method uses

* fix deprecated room.currentState uses

* fix deprecated import/export room keys func

* fix merge issues in image pack file

* fix remaining issues in image pack file

* start indexedDBStore

* update package lock and vite-plugin-top-level-await

* user session settings - WIP

* add useAsync hook

* add password stage uia

* add uia flow matrix error hook

* add UIA action component

* add options to delete sessions

* add sso uia stage

* fix SSO stage complete error

* encryption - WIP

* update user settings encryption terminology

* add default variant to password input

* use password input in uia password stage

* add options for local backup in user settings

* remove typo in import local backup password input label

* online backup - WIP

* fix uia sso action

* move access token settings from about to developer tools

* merge encryption tab into sessions and rename it to devices

* add device placeholder tile

* add logout dialog

* add logout button for current device

* move other devices in component

* render unverified device verification tile

* add learn more section for current device verification

* add device verification status badge

* add info card component

* add index file for password input component

* add types for secret storage

* add component to access secret storage key

* manual verification - WIP

* update matrix-js-sdk to v35

* add manual verification

* use react query for device list

* show unverified tab on sidebar

* fix device list updates

* add session key details to current device

* render restore encryption backup

* fix loading state of restore backup

* fix unverified tab settings closes after verification

* key backup tile - WIP

* fix unverified tab badge

* rename session key to device key in device tile

* improve backup restore functionality

* fix restore button enabled after layout reload during restoring backup

* update backup info on status change

* add backup disconnection failures

* add device verification using sas

* restore backup after verification

* show option to logout on startup error screen

* fix key backup hook update on decryption key cached

* add option to enable device verification

* add device verification reset dialog

* add logout button in settings drawer

* add encrypted message lost on logout

* fix backup restore never finish with 0 keys

* fix setup dialog hides when enabling device verification

* show backup details in menu

* update setup device verification body copy

* replace deprecated method

* fix displayname appear as mxid in settings

* remove old refactored codes

* fix types
This commit is contained in:
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

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

74
src/app/hooks/useTheme.ts Normal file
View file

@ -0,0 +1,74 @@
import { lightTheme } from 'folds';
import { useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, darkTheme, silverTheme } from '../../colors.css';
export enum ThemeKind {
Light = 'light',
Dark = 'dark',
}
export type Theme = {
id: string;
kind: ThemeKind;
classNames: string[];
};
export const LightTheme: Theme = {
id: 'light-theme',
kind: ThemeKind.Light,
classNames: [lightTheme, onLightFontWeight, 'prism-light'],
};
export const SilverTheme: Theme = {
id: 'silver-theme',
kind: ThemeKind.Light,
classNames: ['silver-theme', silverTheme, onLightFontWeight, 'prism-light'],
};
export const DarkTheme: Theme = {
id: 'dark-theme',
kind: ThemeKind.Dark,
classNames: ['dark-theme', darkTheme, onDarkFontWeight, 'prism-dark'],
};
export const ButterTheme: Theme = {
id: 'butter-theme',
kind: ThemeKind.Dark,
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
};
export const useThemes = (): Theme[] => {
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
return themes;
};
export const useThemeNames = (): Record<string, string> =>
useMemo(
() => ({
[LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver',
[DarkTheme.id]: 'Dark',
[ButterTheme.id]: 'Butter',
}),
[]
);
export const useSystemThemeKind = (): ThemeKind => {
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);
const [themeKind, setThemeKind] = useState<ThemeKind>(
darkModeQueryList.matches ? ThemeKind.Dark : ThemeKind.Light
);
useEffect(() => {
const handleMediaQueryChange = () => {
setThemeKind(darkModeQueryList.matches ? ThemeKind.Dark : ThemeKind.Light);
};
darkModeQueryList.addEventListener('change', handleMediaQueryChange);
return () => {
darkModeQueryList.removeEventListener('change', handleMediaQueryChange);
};
}, [darkModeQueryList, setThemeKind]);
return themeKind;
};

View file

@ -1,4 +1,4 @@
import { AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { AuthType, IAuthData, MatrixError, UIAFlow } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react';
import {
getSupportedUIAFlows,
@ -94,3 +94,12 @@ export const useUIAFlow = (authData: IAuthData, uiaFlow: UIAFlow): UIAFlowInterf
getStageInfo,
};
};
export const useUIAMatrixError = (
error?: MatrixError
): [undefined, undefined] | [IAuthData, undefined] | [undefined, MatrixError] => {
if (!error) return [undefined, undefined];
if (error.httpStatus === 401) return [error.data as IAuthData, undefined];
return [undefined, error];
};

View file

@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
export type UserProfile = {
avatarUrl?: string;
displayName?: string;
};
export const useUserProfile = (userId: string): UserProfile => {
const mx = useMatrixClient();
const [profile, setProfile] = useState<UserProfile>(() => {
const user = mx.getUser(userId);
return {
avatarUrl: user?.avatarUrl,
displayName: user?.displayName,
};
});
useEffect(() => {
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (event, myUser) => {
setProfile((cp) => ({
...cp,
avatarUrl: myUser.avatarUrl,
}));
};
const onDisplayNameChange: UserEventHandlerMap[UserEvent.DisplayName] = (event, myUser) => {
setProfile((cp) => ({
...cp,
displayName: myUser.displayName,
}));
};
mx.getProfileInfo(userId).then((info) =>
setProfile({
avatarUrl: info.avatar_url,
displayName: info.displayname,
})
);
user?.on(UserEvent.AvatarUrl, onAvatarChange);
user?.on(UserEvent.DisplayName, onDisplayNameChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
user?.removeListener(UserEvent.DisplayName, onDisplayNameChange);
};
}, [mx, userId]);
return profile;
};

View file

@ -0,0 +1,16 @@
import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto-api';
import { useEffect } from 'react';
import { useMatrixClient } from './useMatrixClient';
export const useUserTrustStatusChange = (
onChange: CryptoEventHandlerMap[CryptoEvent.UserTrustStatusChanged]
) => {
const mx = useMatrixClient();
useEffect(() => {
mx.on(CryptoEvent.UserTrustStatusChanged, onChange);
return () => {
mx.removeListener(CryptoEvent.UserTrustStatusChanged, onChange);
};
}, [mx, onChange]);
};

View file

@ -0,0 +1,87 @@
import { useCallback, useEffect, useState } from 'react';
import {
CryptoEvent,
CryptoEventHandlerMap,
VerificationPhase,
VerificationRequest,
VerificationRequestEvent,
VerificationRequestEventHandlerMap,
Verifier,
VerifierEvent,
VerifierEventHandlerMap,
} from 'matrix-js-sdk/lib/crypto-api';
import { useMatrixClient } from './useMatrixClient';
export const useVerificationRequestReceived = (
onRequest: CryptoEventHandlerMap[CryptoEvent.VerificationRequestReceived]
) => {
const mx = useMatrixClient();
useEffect(() => {
mx.on(CryptoEvent.VerificationRequestReceived, onRequest);
return () => {
mx.removeListener(CryptoEvent.VerificationRequestReceived, onRequest);
};
}, [mx, onRequest]);
};
export const useVerificationRequestChange = (
request: VerificationRequest,
onChange: VerificationRequestEventHandlerMap[VerificationRequestEvent.Change]
) => {
useEffect(() => {
request.on(VerificationRequestEvent.Change, onChange);
return () => {
request.removeListener(VerificationRequestEvent.Change, onChange);
};
}, [request, onChange]);
};
export const useVerificationRequestPhase = (request: VerificationRequest): VerificationPhase => {
const [phase, setPhase] = useState(() => request.phase);
useVerificationRequestChange(
request,
useCallback(() => {
setPhase(request.phase);
}, [request])
);
return phase;
};
export const useVerifierCancel = (
verifier: Verifier,
onCallback: VerifierEventHandlerMap[VerifierEvent.Cancel]
) => {
useEffect(() => {
verifier.on(VerifierEvent.Cancel, onCallback);
return () => {
verifier.removeListener(VerifierEvent.Cancel, onCallback);
};
}, [verifier, onCallback]);
};
export const useVerifierShowSas = (
verifier: Verifier,
onCallback: VerifierEventHandlerMap[VerifierEvent.ShowSas]
) => {
useEffect(() => {
verifier.on(VerifierEvent.ShowSas, onCallback);
return () => {
verifier.removeListener(VerifierEvent.ShowSas, onCallback);
};
}, [verifier, onCallback]);
};
export const useVerifierShowReciprocateQr = (
verifier: Verifier,
onCallback: VerifierEventHandlerMap[VerifierEvent.ShowReciprocateQr]
) => {
useEffect(() => {
verifier.on(VerifierEvent.ShowReciprocateQr, onCallback);
return () => {
verifier.removeListener(VerifierEvent.ShowReciprocateQr, onCallback);
};
}, [verifier, onCallback]);
};