mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-13 02:30:29 +03:00
redesigned app settings and switch to rust crypto (#1988)
* rework general settings * account settings - WIP * add missing key prop * add object url hook * extract wide modal styles * profile settings and image editor - WIP * add outline style to upload card * remove file param from bind upload atom hook * add compact variant to upload card * add compact upload card renderer * add option to update profile avatar * add option to change profile displayname * allow displayname change based on capabilities check * rearrange settings components into folders * add system notification settings * add initial page param in settings * convert account data hook to typescript * add push rule hook * add notification mode hook * add notification mode switcher component * add all messages notification settings options * add special messages notification settings * add keyword notifications * add ignored users section * improve ignore user list strings * add about settings * add access token option in about settings * add developer tools settings * add expand button to account data dev tool option * update folds * fix editable active element textarea check * do not close dialog when editable element in focus * add text area plugins * add text area intent handler hook * add newline intent mod in text area * add next line hotkey in text area intent hook * add syntax error position dom utility function * add account data editor * add button to send new account data in dev tools * improve custom emoji plugin * add more custom emojis hooks * add text util css * add word break in setting tile title and description * emojis and sticker user settings - WIP * view image packs from settings * emoji pack editing - WIP * add option to edit pack meta * change saved changes message * add image edit and delete controls * add option to upload pack images and apply changes * fix state event type when updating image pack * lazy load pack image tile img * hide upload image button when user can not edit pack * add option to add or remove global image packs * upgrade to rust crypto (#2168) * update matrix js sdk * remove dead code * use rust crypto * update setPowerLevel usage * fix types * fix deprecated isRoomEncrypted method uses * fix deprecated room.currentState uses * fix deprecated import/export room keys func * fix merge issues in image pack file * fix remaining issues in image pack file * start indexedDBStore * update package lock and vite-plugin-top-level-await * user session settings - WIP * add useAsync hook * add password stage uia * add uia flow matrix error hook * add UIA action component * add options to delete sessions * add sso uia stage * fix SSO stage complete error * encryption - WIP * update user settings encryption terminology * add default variant to password input * use password input in uia password stage * add options for local backup in user settings * remove typo in import local backup password input label * online backup - WIP * fix uia sso action * move access token settings from about to developer tools * merge encryption tab into sessions and rename it to devices * add device placeholder tile * add logout dialog * add logout button for current device * move other devices in component * render unverified device verification tile * add learn more section for current device verification * add device verification status badge * add info card component * add index file for password input component * add types for secret storage * add component to access secret storage key * manual verification - WIP * update matrix-js-sdk to v35 * add manual verification * use react query for device list * show unverified tab on sidebar * fix device list updates * add session key details to current device * render restore encryption backup * fix loading state of restore backup * fix unverified tab settings closes after verification * key backup tile - WIP * fix unverified tab badge * rename session key to device key in device tile * improve backup restore functionality * fix restore button enabled after layout reload during restoring backup * update backup info on status change * add backup disconnection failures * add device verification using sas * restore backup after verification * show option to logout on startup error screen * fix key backup hook update on decryption key cached * add option to enable device verification * add device verification reset dialog * add logout button in settings drawer * add encrypted message lost on logout * fix backup restore never finish with 0 keys * fix setup dialog hides when enabling device verification * show backup details in menu * update setup device verification body copy * replace deprecated method * fix displayname appear as mxid in settings * remove old refactored codes * fix types
This commit is contained in:
parent
f5d68fcc22
commit
56b754153a
196 changed files with 14171 additions and 8403 deletions
|
|
@ -3,7 +3,8 @@ import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
|
|||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
||||
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { useSpace } from '../../hooks/useSpace';
|
||||
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
|
||||
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
|
||||
|
|
@ -258,7 +259,7 @@ export function Lobby() {
|
|||
const joinRuleContent = getStateEvent(
|
||||
itemRoom,
|
||||
StateEvent.RoomJoinRules
|
||||
)?.getContent<IJoinRuleEventContent>();
|
||||
)?.getContent<RoomJoinRulesEventContent>();
|
||||
|
||||
if (joinRuleContent) {
|
||||
const allow =
|
||||
|
|
|
|||
|
|
@ -56,7 +56,13 @@ import {
|
|||
} from '../../components/editor';
|
||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import {
|
||||
TUploadContent,
|
||||
encryptFile,
|
||||
getImageInfo,
|
||||
getMxIdLocalPart,
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||
|
|
@ -157,7 +163,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
const safeFiles = files.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (mx.isRoomEncrypted(roomId)) {
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
|
|
@ -172,7 +178,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
item: fileItems,
|
||||
});
|
||||
},
|
||||
[setSelectedFiles, roomId, mx]
|
||||
[setSelectedFiles, room]
|
||||
);
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handlePaste = useFilePasteHandler(handleFiles);
|
||||
|
|
@ -413,7 +419,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
<UploadCardRenderer
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
file={fileItem.file}
|
||||
isEncrypted={!!fileItem.encInfo}
|
||||
uploadAtom={roomUploadAtomFamily(fileItem.file)}
|
||||
onRemove={handleRemoveUpload}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ import {
|
|||
reactionOrEditEvent,
|
||||
} from '../../utils/room';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||
import { openProfileViewer } from '../../../client/action/navigation';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||
|
|
@ -336,7 +336,10 @@ const useTimelinePagination = (
|
|||
backwards ? Direction.Backward : Direction.Forward
|
||||
) ?? timelineToPaginate;
|
||||
// Decrypt all event ahead of render cycle
|
||||
if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
|
||||
const roomId = fetchedTimeline.getRoomId();
|
||||
const room = roomId ? mx.getRoom(roomId) : null;
|
||||
|
||||
if (room?.hasEncryptionStateEvent()) {
|
||||
await to(decryptAllTimelineEvent(mx, fetchedTimeline));
|
||||
}
|
||||
|
||||
|
|
@ -421,7 +424,6 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
|
|||
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const encryptedRoom = mx.isRoomEncrypted(room.roomId);
|
||||
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
|
|
@ -429,7 +431,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview;
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
|
|
@ -1030,7 +1032,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === 2}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
|
|
@ -1126,7 +1128,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === 2}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1211,7 +1213,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const parsed = parseMemberEvent(mEvent);
|
||||
|
||||
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -1244,7 +1248,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
|
||||
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -1278,7 +1284,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
|
||||
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -1312,7 +1320,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
|
||||
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -1348,7 +1358,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
|
||||
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -1389,7 +1401,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
|
||||
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
|
||||
const timeJSX = (
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -1544,7 +1558,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<div
|
||||
style={{
|
||||
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
|
||||
messageLayout === 1 ? config.space.S400 : toRem(64)
|
||||
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
|
|
@ -1552,7 +1566,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
</div>
|
||||
)}
|
||||
{(canPaginateBack || !rangeAtStart) &&
|
||||
(messageLayout === 1 ? (
|
||||
(messageLayout === MessageLayout.Compact ? (
|
||||
<>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
|
|
@ -1587,7 +1601,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
{getItems().map(eventRenderer)}
|
||||
|
||||
{(!liveTimelineLinked || !rangeAtEnd) &&
|
||||
(messageLayout === 1 ? (
|
||||
(messageLayout === MessageLayout.Compact ? (
|
||||
<>
|
||||
<MessageBase ref={observeFrontAnchor}>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
|
|
|
|||
|
|
@ -716,7 +716,7 @@ export const Message = as<'div', MessageProps>(
|
|||
const headerJSX = !collapse && (
|
||||
<Box
|
||||
gap="300"
|
||||
direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
|
||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||
justifyContent="SpaceBetween"
|
||||
alignItems="Baseline"
|
||||
grow="Yes"
|
||||
|
|
@ -728,12 +728,12 @@ export const Message = as<'div', MessageProps>(
|
|||
onContextMenu={onUserClick}
|
||||
onClick={onUsernameClick}
|
||||
>
|
||||
<Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
|
||||
<Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate>
|
||||
<b>{senderDisplayName}</b>
|
||||
</Text>
|
||||
</Username>
|
||||
<Box shrink="No" gap="100">
|
||||
{messageLayout === 0 && hover && (
|
||||
{messageLayout === MessageLayout.Modern && hover && (
|
||||
<>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{senderId}
|
||||
|
|
@ -743,12 +743,12 @@ export const Message = as<'div', MessageProps>(
|
|||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === 1} />
|
||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const avatarJSX = !collapse && messageLayout !== 1 && (
|
||||
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
|
||||
<AvatarBase>
|
||||
<Avatar
|
||||
className={css.MessageAvatar}
|
||||
|
|
@ -1043,18 +1043,18 @@ export const Message = as<'div', MessageProps>(
|
|||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{messageLayout === 1 && (
|
||||
{messageLayout === MessageLayout.Compact && (
|
||||
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
|
||||
{msgContentJSX}
|
||||
</CompactLayout>
|
||||
)}
|
||||
{messageLayout === 2 && (
|
||||
{messageLayout === MessageLayout.Bubble && (
|
||||
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
||||
{headerJSX}
|
||||
{msgContentJSX}
|
||||
</BubbleLayout>
|
||||
)}
|
||||
{messageLayout !== 1 && messageLayout !== 2 && (
|
||||
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
|
||||
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
||||
{headerJSX}
|
||||
{msgContentJSX}
|
||||
|
|
|
|||
234
src/app/features/settings/Settings.tsx
Normal file
234
src/app/features/settings/Settings.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
IconSrc,
|
||||
MenuItem,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { General } from './general';
|
||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { Account } from './account';
|
||||
import { useUserProfile } from '../../hooks/useUserProfile';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { Notifications } from './notifications';
|
||||
import { Devices } from './devices';
|
||||
import { EmojisStickers } from './emojis-stickers';
|
||||
import { DeveloperTools } from './developer-tools';
|
||||
import { About } from './about';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
NotificationPage,
|
||||
DevicesPage,
|
||||
EmojisStickersPage,
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
|
||||
type SettingsMenuItem = {
|
||||
page: SettingsPages;
|
||||
name: string;
|
||||
icon: IconSrc;
|
||||
};
|
||||
|
||||
const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
page: SettingsPages.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.AccountPage,
|
||||
name: 'Account',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.NotificationPage,
|
||||
name: 'Notifications',
|
||||
icon: Icons.Bell,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.DevicesPage,
|
||||
name: 'Devices',
|
||||
icon: Icons.Category,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.AboutPage,
|
||||
name: 'About',
|
||||
icon: Icons.Info,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
type SettingsProps = {
|
||||
initialPage?: SettingsPages;
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const userId = mx.getUserId()!;
|
||||
const profile = useUserProfile(userId);
|
||||
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarUrl = profile.avatarUrl
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
|
||||
if (initialPage) return initialPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
|
||||
});
|
||||
const menuItems = useSettingsMenuItems();
|
||||
|
||||
const handlePageRequestClose = () => {
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
setActivePage(undefined);
|
||||
return;
|
||||
}
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageRoot
|
||||
nav={
|
||||
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
|
||||
<PageNav size="300">
|
||||
<PageNavHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Avatar size="200" radii="300">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
Settings
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<IconButton onClick={requestClose} variant="Background">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<PageNavContent>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
{menuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
variant="Background"
|
||||
radii="400"
|
||||
aria-pressed={activePage === item.page}
|
||||
before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
|
||||
onClick={() => setActivePage(item.page)}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
|
||||
}}
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
</PageNavContent>
|
||||
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
|
||||
<UseStateProvider initial={false}>
|
||||
{(logout, setLogout) => (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="Pill"
|
||||
before={<Icon src={Icons.Power} size="100" />}
|
||||
onClick={() => setLogout(true)}
|
||||
>
|
||||
<Text size="B400">Logout</Text>
|
||||
</Button>
|
||||
{logout && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
onDeactivate: () => setLogout(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<LogoutDialog handleClose={() => setLogout(false)} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageNav>
|
||||
)
|
||||
}
|
||||
>
|
||||
{activePage === SettingsPages.GeneralPage && (
|
||||
<General requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.AccountPage && (
|
||||
<Account requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.NotificationPage && (
|
||||
<Notifications requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DevicesPage && (
|
||||
<Devices requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.EmojisStickersPage && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DeveloperToolsPage && (
|
||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
||||
</PageRoot>
|
||||
);
|
||||
}
|
||||
245
src/app/features/settings/about/About.tsx
Normal file
245
src/app/features/settings/about/About.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import React from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
|
||||
import cons from '../../../../client/state/cons';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
type AboutProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function About({ requestClose }: AboutProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
About
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Box gap="400">
|
||||
<Box shrink="No">
|
||||
<img
|
||||
style={{ width: toRem(60), height: toRem(60) }}
|
||||
src={CinnySVG}
|
||||
alt="Cinny logo"
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="100">
|
||||
<Box gap="100" alignItems="End">
|
||||
<Text size="H3">Cinny</Text>
|
||||
<Text size="T200">v{cons.version}</Text>
|
||||
</Box>
|
||||
<Text>Yet another matrix client.</Text>
|
||||
</Box>
|
||||
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://github.com/cinnyapp/cinny"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
before={<Icon src={Icons.Code} size="100" filled />}
|
||||
>
|
||||
<Text size="B300">Source Code</Text>
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://cinny.in/#sponsor"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
before={<Icon src={Icons.Heart} size="100" filled />}
|
||||
>
|
||||
<Text size="B300">Support</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Clear Cache & Reload"
|
||||
description="Clear all your locally stored data and reload from server."
|
||||
after={
|
||||
<Button
|
||||
onClick={() => clearCacheAndReload(mx)}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">Clear Cache</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Credits</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<Box
|
||||
as="ul"
|
||||
direction="Column"
|
||||
gap="200"
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: config.space.S400,
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
<Text size="T300">
|
||||
The{' '}
|
||||
<a
|
||||
href="https://github.com/matrix-org/matrix-js-sdk"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
matrix-js-sdk
|
||||
</a>{' '}
|
||||
is ©{' '}
|
||||
<a
|
||||
href="https://matrix.org/foundation"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
The Matrix.org Foundation C.I.C
|
||||
</a>{' '}
|
||||
used under the terms of{' '}
|
||||
<a
|
||||
href="http://www.apache.org/licenses/LICENSE-2.0"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Apache 2.0
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text size="T300">
|
||||
The{' '}
|
||||
<a
|
||||
href="https://github.com/mozilla/twemoji-colr"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
twemoji-colr
|
||||
</a>{' '}
|
||||
font is ©{' '}
|
||||
<a href="https://mozilla.org/" target="_blank" rel="noreferrer noopener">
|
||||
Mozilla Foundation
|
||||
</a>{' '}
|
||||
used under the terms of{' '}
|
||||
<a
|
||||
href="http://www.apache.org/licenses/LICENSE-2.0"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Apache 2.0
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text size="T300">
|
||||
The{' '}
|
||||
<a
|
||||
href="https://twemoji.twitter.com"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Twemoji
|
||||
</a>{' '}
|
||||
emoji art is ©{' '}
|
||||
<a
|
||||
href="https://twemoji.twitter.com"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Twitter, Inc and other contributors
|
||||
</a>{' '}
|
||||
used under the terms of{' '}
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by/4.0/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
CC-BY 4.0
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text size="T300">
|
||||
The{' '}
|
||||
<a
|
||||
href="https://material.io/design/sound/sound-resources.html"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Material sound resources
|
||||
</a>{' '}
|
||||
are ©{' '}
|
||||
<a href="https://google.com" target="_blank" rel="noreferrer noopener">
|
||||
Google
|
||||
</a>{' '}
|
||||
used under the terms of{' '}
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by/4.0/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
CC-BY 4.0
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</li>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
1
src/app/features/settings/about/index.ts
Normal file
1
src/app/features/settings/about/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './About';
|
||||
428
src/app/features/settings/account/Account.tsx
Normal file
428
src/app/features/settings/account/Account.tsx
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
import React, {
|
||||
ChangeEventHandler,
|
||||
FormEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Scroll,
|
||||
Input,
|
||||
Avatar,
|
||||
Button,
|
||||
Chip,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Modal,
|
||||
Dialog,
|
||||
Header,
|
||||
config,
|
||||
Spinner,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { ImageEditor } from '../../../components/image-editor';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
|
||||
function MatrixId() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Matrix ID</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={userId}
|
||||
after={
|
||||
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
|
||||
<Text size="T200">Copy</Text>
|
||||
</Chip>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
userId: string;
|
||||
};
|
||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const capabilities = useCapabilities();
|
||||
const [alertRemove, setAlertRemove] = useState(false);
|
||||
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
|
||||
|
||||
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarUrl = profile.avatarUrl
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const [imageFile, setImageFile] = useState<File>();
|
||||
const imageFileURL = useObjectURL(imageFile);
|
||||
const uploadAtom = useMemo(() => {
|
||||
if (imageFile) return createUploadAtom(imageFile);
|
||||
return undefined;
|
||||
}, [imageFile]);
|
||||
|
||||
const pickFile = useFilePicker(setImageFile, false);
|
||||
|
||||
const handleRemoveUpload = useCallback(() => {
|
||||
setImageFile(undefined);
|
||||
}, []);
|
||||
|
||||
const handleUploaded = useCallback(
|
||||
(upload: UploadSuccess) => {
|
||||
const { mxc } = upload;
|
||||
mx.setAvatarUrl(mxc);
|
||||
handleRemoveUpload();
|
||||
},
|
||||
[mx, handleRemoveUpload]
|
||||
);
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
mx.setAvatarUrl('');
|
||||
setAlertRemove(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Avatar
|
||||
</Text>
|
||||
}
|
||||
after={
|
||||
<Avatar size="500" radii="300">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
{uploadAtom ? (
|
||||
<Box gap="200" direction="Column">
|
||||
<CompactUploadCardRenderer
|
||||
uploadAtom={uploadAtom}
|
||||
onRemove={handleRemoveUpload}
|
||||
onComplete={handleUploaded}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box gap="200">
|
||||
<Button
|
||||
onClick={() => pickFile('image/*')}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={disableSetAvatar}
|
||||
>
|
||||
<Text size="B300">Upload</Text>
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={disableSetAvatar}
|
||||
onClick={() => setAlertRemove(true)}
|
||||
>
|
||||
<Text size="B300">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{imageFileURL && (
|
||||
<Overlay open={false} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleRemoveUpload,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal className={ModalWide} variant="Surface" size="500">
|
||||
<ImageEditor
|
||||
name={imageFile?.name ?? 'Unnamed'}
|
||||
url={imageFileURL}
|
||||
requestClose={handleRemoveUpload}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
|
||||
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setAlertRemove(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">Remove Avatar</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
|
||||
</Box>
|
||||
<Button variant="Critical" onClick={handleRemoveAvatar}>
|
||||
<Text size="B400">Remove</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const capabilities = useCapabilities();
|
||||
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
|
||||
|
||||
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const [displayName, setDisplayName] = useState<string>();
|
||||
|
||||
const [changeState, changeDisplayName] = useAsyncCallback(
|
||||
useCallback((name: string) => mx.setDisplayName(name), [mx])
|
||||
);
|
||||
const changingDisplayName = changeState.status === AsyncStatus.Loading;
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(defaultDisplayName);
|
||||
}, [defaultDisplayName]);
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const name = evt.currentTarget.value;
|
||||
setDisplayName(name);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setDisplayName(defaultDisplayName);
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (changingDisplayName) return;
|
||||
|
||||
const target = evt.target as HTMLFormElement | undefined;
|
||||
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
|
||||
const name = displayNameInput?.value;
|
||||
if (!name) return;
|
||||
|
||||
changeDisplayName(name);
|
||||
};
|
||||
|
||||
const hasChanges = displayName !== defaultDisplayName;
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Display Name
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={handleSubmit}
|
||||
gap="200"
|
||||
aria-disabled={changingDisplayName || disableSetDisplayname}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
required
|
||||
name="displayNameInput"
|
||||
value={displayName}
|
||||
onChange={handleChange}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
style={{ paddingRight: config.space.S200 }}
|
||||
readOnly={changingDisplayName || disableSetDisplayname}
|
||||
after={
|
||||
hasChanges &&
|
||||
!changingDisplayName && (
|
||||
<IconButton
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="400"
|
||||
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || changingDisplayName}
|
||||
type="submit"
|
||||
>
|
||||
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
function Profile() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const profile = useUserProfile(userId);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Profile</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<ProfileAvatar userId={userId} profile={profile} />
|
||||
<ProfileDisplayName userId={userId} profile={profile} />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactInformation() {
|
||||
const mx = useMatrixClient();
|
||||
const [threePIdsState, loadThreePIds] = useAsyncCallback(
|
||||
useCallback(() => mx.getThreePids(), [mx])
|
||||
);
|
||||
const threePIds =
|
||||
threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
|
||||
|
||||
const emailIds = threePIds?.filter((id) => id.medium === 'email');
|
||||
|
||||
useEffect(() => {
|
||||
loadThreePIds();
|
||||
}, [loadThreePIds]);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Contact Information</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile title="Email Address" description="Email address attached to your account.">
|
||||
<Box>
|
||||
{emailIds?.map((email) => (
|
||||
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
|
||||
<Text size="T200">{email.address}</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type AccountProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function Account({ requestClose }: AccountProps) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Account
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Profile />
|
||||
<MatrixId />
|
||||
<ContactInformation />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
1
src/app/features/settings/account/index.ts
Normal file
1
src/app/features/settings/account/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Account';
|
||||
211
src/app/features/settings/developer-tools/AccountDataEditor.tsx
Normal file
211
src/app/features/settings/developer-tools/AccountDataEditor.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import React, {
|
||||
FormEventHandler,
|
||||
KeyboardEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
as,
|
||||
Box,
|
||||
Header,
|
||||
Text,
|
||||
Icon,
|
||||
Icons,
|
||||
IconButton,
|
||||
Input,
|
||||
Button,
|
||||
TextArea as TextAreaComponent,
|
||||
color,
|
||||
Spinner,
|
||||
} from 'folds';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import * as css from './styles.css';
|
||||
import { useTextAreaIntentHandler } from '../../../hooks/useTextAreaIntent';
|
||||
import { Cursor, Intent, TextArea, TextAreaOperations } from '../../../plugins/text-area';
|
||||
import { GetTarget } from '../../../plugins/text-area/type';
|
||||
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||
|
||||
export type AccountDataEditorProps = {
|
||||
type?: string;
|
||||
content?: object;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export const AccountDataEditor = as<'div', AccountDataEditorProps>(
|
||||
({ type, content, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const defaultContent = useMemo(
|
||||
() => JSON.stringify(content, null, EDITOR_INTENT_SPACE_COUNT),
|
||||
[content]
|
||||
);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [jsonError, setJSONError] = useState<SyntaxError>();
|
||||
|
||||
const getTarget: GetTarget = useCallback(() => {
|
||||
const target = textAreaRef.current;
|
||||
if (!target) throw new Error('TextArea element not found!');
|
||||
return target;
|
||||
}, []);
|
||||
|
||||
const { textArea, operations, intent } = useMemo(() => {
|
||||
const ta = new TextArea(getTarget);
|
||||
const op = new TextAreaOperations(getTarget);
|
||||
return {
|
||||
textArea: ta,
|
||||
operations: op,
|
||||
intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
|
||||
};
|
||||
}, [getTarget]);
|
||||
|
||||
const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
|
||||
intentHandler(evt);
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
const cursor = Cursor.fromTextAreaElement(getTarget());
|
||||
operations.deselect(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
|
||||
useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
|
||||
);
|
||||
const submitting = submitState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
const target = evt.target as HTMLFormElement | undefined;
|
||||
const typeInput = target?.typeInput as HTMLInputElement | undefined;
|
||||
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
|
||||
if (!typeInput || !contentTextArea) return;
|
||||
|
||||
const typeStr = typeInput.value.trim();
|
||||
const contentStr = contentTextArea.value.trim();
|
||||
|
||||
let parsedContent: object;
|
||||
try {
|
||||
parsedContent = JSON.parse(contentStr);
|
||||
} catch (e) {
|
||||
setJSONError(e as SyntaxError);
|
||||
return;
|
||||
}
|
||||
setJSONError(undefined);
|
||||
|
||||
if (
|
||||
!typeStr ||
|
||||
parsedContent === null ||
|
||||
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
submit(typeStr, parsedContent);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (jsonError) {
|
||||
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
|
||||
const cursor = new Cursor(errorPosition, errorPosition, 'none');
|
||||
operations.select(cursor);
|
||||
getTarget()?.focus();
|
||||
}
|
||||
}, [jsonError, operations, getTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submitState.status === AsyncStatus.Success) {
|
||||
requestClose();
|
||||
}
|
||||
}, [submitState, requestClose]);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" {...props} ref={ref}>
|
||||
<Header className={css.EditorHeader} size="600">
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Account Data
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={handleSubmit}
|
||||
grow="Yes"
|
||||
className={css.EditorContent}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
aria-disabled={submitting}
|
||||
>
|
||||
<Box shrink="No" direction="Column" gap="100">
|
||||
<Text size="L400">Type</Text>
|
||||
<Box gap="300">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
name="typeInput"
|
||||
size="400"
|
||||
readOnly={!!type || submitting}
|
||||
defaultValue={type}
|
||||
required
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="400"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
|
||||
>
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{submitState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>{submitState.error.message}</b>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Box shrink="No">
|
||||
<Text size="L400">JSON Content</Text>
|
||||
</Box>
|
||||
<TextAreaComponent
|
||||
ref={textAreaRef}
|
||||
name="contentTextArea"
|
||||
className={css.EditorTextArea}
|
||||
onKeyDown={handleKeyDown}
|
||||
defaultValue={defaultContent}
|
||||
resize="None"
|
||||
spellCheck="false"
|
||||
required
|
||||
readOnly={submitting}
|
||||
/>
|
||||
{jsonError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>
|
||||
{jsonError.name}: {jsonError.message}
|
||||
</b>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
302
src/app/features/settings/developer-tools/DevelopTools.tsx
Normal file
302
src/app/features/settings/developer-tools/DevelopTools.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Scroll,
|
||||
Switch,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Modal,
|
||||
Chip,
|
||||
Button,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Menu,
|
||||
config,
|
||||
MenuItem,
|
||||
} from 'folds';
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
|
||||
import { TextViewer } from '../../../components/text-viewer';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { AccountDataEditor } from './AccountDataEditor';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
|
||||
function AccountData() {
|
||||
const mx = useMatrixClient();
|
||||
const [view, setView] = useState(false);
|
||||
const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
|
||||
const [selectedEvent, selectEvent] = useState<MatrixEvent>();
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const [selectedOption, selectOption] = useState<'edit' | 'inspect'>();
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
() => setAccountData(Array.from(mx.store.accountData.values())),
|
||||
[mx, setAccountData]
|
||||
)
|
||||
);
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const target = evt.currentTarget;
|
||||
const eventType = target.getAttribute('data-event-type');
|
||||
if (eventType) {
|
||||
const mEvent = accountData.find((mEvt) => mEvt.getType() === eventType);
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
selectEvent(mEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuClose = () => setMenuCords(undefined);
|
||||
|
||||
const handleEdit = () => {
|
||||
selectOption('edit');
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
const handleInspect = () => {
|
||||
selectOption('inspect');
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
const handleClose = useCallback(() => {
|
||||
selectEvent(undefined);
|
||||
selectOption(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Account Data</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Global"
|
||||
description="Data stored in your global account data."
|
||||
after={
|
||||
<Button
|
||||
onClick={() => setView(!view)}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
before={
|
||||
<Icon src={view ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
|
||||
}
|
||||
>
|
||||
<Text size="B300">{view ? 'Collapse' : 'Expand'}</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{view && (
|
||||
<SettingTile>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Types</Text>
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
onClick={handleEdit}
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
>
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Chip>
|
||||
{accountData.map((mEvent) => (
|
||||
<Chip
|
||||
key={mEvent.getType()}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
aria-pressed={menuCords && selectedEvent?.getType() === mEvent.getType()}
|
||||
onClick={handleMenu}
|
||||
data-event-type={mEvent.getType()}
|
||||
>
|
||||
<Text size="T200" truncate>
|
||||
{mEvent.getType()}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
)}
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleMenuClose,
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem size="300" variant="Surface" radii="300" onClick={handleInspect}>
|
||||
<Text size="T300">Inspect</Text>
|
||||
</MenuItem>
|
||||
<MenuItem size="300" variant="Surface" radii="300" onClick={handleEdit}>
|
||||
<Text size="T300">Edit</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{selectedEvent && selectedOption === 'inspect' && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="500">
|
||||
<TextViewer
|
||||
name={selectedEvent.getType() ?? 'Source Code'}
|
||||
langName="json"
|
||||
text={JSON.stringify(selectedEvent.getContent(), null, 2)}
|
||||
requestClose={handleClose}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{selectedOption === 'edit' && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="500">
|
||||
<AccountDataEditor
|
||||
type={selectedEvent?.getType()}
|
||||
content={selectedEvent?.getContent()}
|
||||
requestClose={handleClose}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Developer Tools
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Enable Developer Tools"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={developerTools}
|
||||
onChange={setDeveloperTools}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{developerTools && (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Access Token"
|
||||
description="Copy access token to clipboard."
|
||||
after={
|
||||
<Button
|
||||
onClick={() =>
|
||||
copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
|
||||
}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">Copy</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
{developerTools && <AccountData />}
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
1
src/app/features/settings/developer-tools/index.ts
Normal file
1
src/app/features/settings/developer-tools/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './DevelopTools';
|
||||
24
src/app/features/settings/developer-tools/styles.css.ts
Normal file
24
src/app/features/settings/developer-tools/styles.css.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
|
||||
export const EditorHeader = style([
|
||||
DefaultReset,
|
||||
{
|
||||
paddingLeft: config.space.S400,
|
||||
paddingRight: config.space.S200,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
flexShrink: 0,
|
||||
gap: config.space.S200,
|
||||
},
|
||||
]);
|
||||
|
||||
export const EditorContent = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S400,
|
||||
},
|
||||
]);
|
||||
|
||||
export const EditorTextArea = style({
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
332
src/app/features/settings/devices/DeviceTile.tsx
Normal file
332
src/app/features/settings/devices/DeviceTile.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import React, { FormEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Chip,
|
||||
Input,
|
||||
Button,
|
||||
color,
|
||||
Spinner,
|
||||
toRem,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
} from 'folds';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { LogoutDialog } from '../../../components/LogoutDialog';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
export function DeviceTilePlaceholder() {
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
style={{ height: toRem(66) }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceActiveTime({ ts }: { ts: number }) {
|
||||
return (
|
||||
<Text className={BreakWord} size="T200">
|
||||
<Text size="Inherit" as="span" priority="300">
|
||||
{'Last activity: '}
|
||||
</Text>
|
||||
<>
|
||||
{today(ts) && 'Today'}
|
||||
{yesterday(ts) && 'Yesterday'}
|
||||
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
|
||||
</>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceDetails({ device }: { device: IMyDevice }) {
|
||||
return (
|
||||
<>
|
||||
{typeof device.device_id === 'string' && (
|
||||
<Text className={BreakWord} size="T200" priority="300">
|
||||
Device ID: <i>{device.device_id}</i>
|
||||
</Text>
|
||||
)}
|
||||
{typeof device.last_seen_ip === 'string' && (
|
||||
<Text className={BreakWord} size="T200" priority="300">
|
||||
IP Address: <i>{device.last_seen_ip}</i>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DeviceKeyDetailsProps = {
|
||||
crypto: CryptoApi;
|
||||
};
|
||||
export function DeviceKeyDetails({ crypto }: DeviceKeyDetailsProps) {
|
||||
const [keysState, loadKeys] = useAsyncCallback(
|
||||
useCallback(() => {
|
||||
const keys = crypto.getOwnDeviceKeys();
|
||||
return keys;
|
||||
}, [crypto])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
if (keysState.status === AsyncStatus.Error) return null;
|
||||
|
||||
return (
|
||||
<Text className={BreakWord} size="T200" priority="300">
|
||||
Device Key:{' '}
|
||||
<i>{keysState.status === AsyncStatus.Success ? keysState.data.ed25519 : 'loading...'}</i>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
type DeviceRenameProps = {
|
||||
device: IMyDevice;
|
||||
onCancel: () => void;
|
||||
onRename: () => void;
|
||||
refreshDeviceList: () => Promise<void>;
|
||||
};
|
||||
function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceRenameProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [renameState, rename] = useAsyncCallback<void, MatrixError, [string]>(
|
||||
useCallback(
|
||||
async (name: string) => {
|
||||
await mx.setDeviceDetails(device.device_id, { display_name: name });
|
||||
await refreshDeviceList();
|
||||
},
|
||||
[mx, device.device_id, refreshDeviceList]
|
||||
)
|
||||
);
|
||||
|
||||
const renaming = renameState.status === AsyncStatus.Loading;
|
||||
|
||||
useEffect(() => {
|
||||
if (renameState.status === AsyncStatus.Success) {
|
||||
onRename();
|
||||
}
|
||||
}, [renameState, onRename]);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (renaming) return;
|
||||
|
||||
const target = evt.target as HTMLFormElement | undefined;
|
||||
const nameInput = target?.nameInput as HTMLInputElement | undefined;
|
||||
if (!nameInput) return;
|
||||
const deviceName = nameInput.value.trim();
|
||||
if (!deviceName || deviceName === device.display_name) return;
|
||||
|
||||
rename(deviceName);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
|
||||
<Text size="L400">Device Name</Text>
|
||||
<Box gap="200">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
name="nameInput"
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
defaultValue={device.display_name}
|
||||
autoFocus
|
||||
required
|
||||
readOnly={renaming}
|
||||
/>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200">
|
||||
<Button
|
||||
type="submit"
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
fill="Solid"
|
||||
disabled={renaming}
|
||||
before={renaming && <Spinner size="100" variant="Success" fill="Solid" />}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
onClick={onCancel}
|
||||
disabled={renaming}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{renameState.status === AsyncStatus.Error ? (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{renameState.error.message}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T200">Device names are visible to public.</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceLogoutBtn() {
|
||||
const [prompt, setPrompt] = useState(false);
|
||||
|
||||
const handleClose = () => setPrompt(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chip variant="Secondary" fill="Soft" radii="Pill" onClick={() => setPrompt(true)}>
|
||||
<Text size="B300">Logout</Text>
|
||||
</Chip>
|
||||
{prompt && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
onDeactivate: handleClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<LogoutDialog handleClose={handleClose} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DeviceDeleteBtnProps = {
|
||||
deviceId: string;
|
||||
deleted: boolean;
|
||||
onDeleteToggle: (deviceId: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export function DeviceDeleteBtn({
|
||||
deviceId,
|
||||
deleted,
|
||||
onDeleteToggle,
|
||||
disabled,
|
||||
}: DeviceDeleteBtnProps) {
|
||||
return deleted ? (
|
||||
<Chip
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="Pill"
|
||||
onClick={() => onDeleteToggle(deviceId)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text size="B300">Undo</Text>
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="Pill"
|
||||
onClick={() => onDeleteToggle(deviceId)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size="50" src={Icons.Delete} />
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
type DeviceTileProps = {
|
||||
device: IMyDevice;
|
||||
deleted?: boolean;
|
||||
refreshDeviceList: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
options?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
export function DeviceTile({
|
||||
device,
|
||||
deleted,
|
||||
refreshDeviceList,
|
||||
disabled,
|
||||
options,
|
||||
children,
|
||||
}: DeviceTileProps) {
|
||||
const activeTs = device.last_seen_ts;
|
||||
const [details, setDetails] = useState(false);
|
||||
const [edit, setEdit] = useState(false);
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
setEdit(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
before={
|
||||
<IconButton
|
||||
variant={deleted ? 'Critical' : 'Secondary'}
|
||||
outlined={deleted}
|
||||
radii="300"
|
||||
onClick={() => setDetails(!details)}
|
||||
>
|
||||
<Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} />
|
||||
</IconButton>
|
||||
}
|
||||
after={
|
||||
!edit && (
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
{options}
|
||||
{!deleted && (
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
onClick={() => setEdit(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text size="B300">Edit</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="T300">{device.display_name ?? device.device_id}</Text>
|
||||
<Box direction="Column">
|
||||
{typeof activeTs === 'number' && <DeviceActiveTime ts={activeTs} />}
|
||||
{details && (
|
||||
<>
|
||||
<DeviceDetails device={device} />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
{edit && (
|
||||
<DeviceRename
|
||||
device={device}
|
||||
onCancel={() => setEdit(false)}
|
||||
onRename={handleRename}
|
||||
refreshDeviceList={refreshDeviceList}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
src/app/features/settings/devices/Devices.tsx
Normal file
165
src/app/features/settings/devices/Devices.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '../../../hooks/useDeviceList';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { LocalBackup } from './LocalBackup';
|
||||
import { DeviceLogoutBtn, DeviceKeyDetails, DeviceTile, DeviceTilePlaceholder } from './DeviceTile';
|
||||
import { OtherDevices } from './OtherDevices';
|
||||
import {
|
||||
DeviceVerificationOptions,
|
||||
EnableVerification,
|
||||
VerificationStatusBadge,
|
||||
VerifyCurrentDeviceTile,
|
||||
} from './Verification';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
useUnverifiedDeviceCount,
|
||||
VerificationStatus,
|
||||
} from '../../../hooks/useDeviceVerificationStatus';
|
||||
import {
|
||||
useSecretStorageDefaultKeyId,
|
||||
useSecretStorageKeyContent,
|
||||
} from '../../../hooks/useSecretStorage';
|
||||
import { useCrossSigningActive } from '../../../hooks/useCrossSigning';
|
||||
import { BackupRestoreTile } from '../../../components/BackupRestore';
|
||||
|
||||
function DevicesPlaceholder() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<DeviceTilePlaceholder />
|
||||
<DeviceTilePlaceholder />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type DevicesProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function Devices({ requestClose }: DevicesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const crypto = mx.getCrypto();
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
const [devices, refreshDeviceList] = useDeviceList();
|
||||
|
||||
const [currentDevice, otherDevices] = useSplitCurrentDevice(devices);
|
||||
const verificationStatus = useDeviceVerificationStatus(
|
||||
crypto,
|
||||
mx.getSafeUserId(),
|
||||
currentDevice?.device_id
|
||||
);
|
||||
|
||||
const otherDevicesId = useDeviceIds(otherDevices);
|
||||
const unverifiedDeviceCount = useUnverifiedDeviceCount(
|
||||
crypto,
|
||||
mx.getSafeUserId(),
|
||||
otherDevicesId
|
||||
);
|
||||
|
||||
const defaultSecretStorageKeyId = useSecretStorageDefaultKeyId();
|
||||
const defaultSecretStorageKeyContent = useSecretStorageKeyContent(
|
||||
defaultSecretStorageKeyId ?? ''
|
||||
);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Devices
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Security</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Device Verification"
|
||||
description="To verify device identity and grant access to encrypted messages."
|
||||
after={
|
||||
<>
|
||||
<EnableVerification visible={!crossSigningActive} />
|
||||
{crossSigningActive && (
|
||||
<Box gap="200" alignItems="Center">
|
||||
<VerificationStatusBadge
|
||||
verificationStatus={verificationStatus}
|
||||
otherUnverifiedCount={unverifiedDeviceCount}
|
||||
/>
|
||||
<DeviceVerificationOptions />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Current</Text>
|
||||
{currentDevice ? (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<DeviceTile
|
||||
device={currentDevice}
|
||||
refreshDeviceList={refreshDeviceList}
|
||||
options={<DeviceLogoutBtn />}
|
||||
>
|
||||
{crypto && <DeviceKeyDetails crypto={crypto} />}
|
||||
</DeviceTile>
|
||||
{crossSigningActive &&
|
||||
verificationStatus === VerificationStatus.Unverified &&
|
||||
defaultSecretStorageKeyId &&
|
||||
defaultSecretStorageKeyContent && (
|
||||
<VerifyCurrentDeviceTile
|
||||
secretStorageKeyId={defaultSecretStorageKeyId}
|
||||
secretStorageKeyContent={defaultSecretStorageKeyContent}
|
||||
/>
|
||||
)}
|
||||
{crypto && verificationStatus === VerificationStatus.Verified && (
|
||||
<BackupRestoreTile crypto={crypto} />
|
||||
)}
|
||||
</SequenceCard>
|
||||
) : (
|
||||
<DeviceTilePlaceholder />
|
||||
)}
|
||||
</Box>
|
||||
{devices === undefined && <DevicesPlaceholder />}
|
||||
{otherDevices && (
|
||||
<OtherDevices
|
||||
devices={otherDevices}
|
||||
refreshDeviceList={refreshDeviceList}
|
||||
showVerification={
|
||||
crossSigningActive && verificationStatus === VerificationStatus.Verified
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<LocalBackup />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
325
src/app/features/settings/devices/LocalBackup.tsx
Normal file
325
src/app/features/settings/devices/LocalBackup.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
||||
import FileSaver from 'file-saver';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { PasswordInput } from '../../../components/password-input';
|
||||
import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
|
||||
function ExportKeys() {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
|
||||
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
||||
useCallback(
|
||||
async (password) => {
|
||||
const crypto = mx.getCrypto();
|
||||
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
|
||||
const keysJSON = await crypto.exportRoomKeysAsJson();
|
||||
|
||||
const encKeys = await encryptMegolmKeyFile(keysJSON, password);
|
||||
|
||||
const blob = new Blob([encKeys], {
|
||||
type: 'text/plain;charset=us-ascii',
|
||||
});
|
||||
FileSaver.saveAs(blob, 'cinny-keys.txt');
|
||||
},
|
||||
[mx]
|
||||
)
|
||||
);
|
||||
|
||||
const exporting = exportState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (exporting) return;
|
||||
|
||||
const { passwordInput, confirmPasswordInput } = evt.target as HTMLFormElement & {
|
||||
passwordInput: HTMLInputElement;
|
||||
confirmPasswordInput: HTMLInputElement;
|
||||
};
|
||||
|
||||
const password = passwordInput.value;
|
||||
const confirmPassword = confirmPasswordInput.value;
|
||||
|
||||
if (password !== confirmPassword) return;
|
||||
|
||||
exportKeys(password).then(() => {
|
||||
if (alive()) {
|
||||
passwordInput.value = '';
|
||||
confirmPasswordInput.value = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile>
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
|
||||
<Box gap="200" alignItems="End">
|
||||
<ConfirmPasswordMatch initialValue>
|
||||
{(match, doMatch, passRef, confPassRef) => (
|
||||
<>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">New Password</Text>
|
||||
<PasswordInput
|
||||
ref={passRef}
|
||||
name="passwordInput"
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
required
|
||||
onChange={doMatch}
|
||||
readOnly={exporting}
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">Confirm Password</Text>
|
||||
<PasswordInput
|
||||
ref={confPassRef}
|
||||
style={{ color: match ? undefined : color.Critical.Main }}
|
||||
name="confirmPasswordInput"
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
required
|
||||
onChange={doMatch}
|
||||
readOnly={exporting}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</ConfirmPasswordMatch>
|
||||
<Button
|
||||
type="submit"
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={exporting}
|
||||
before={exporting ? <Spinner size="200" variant="Secondary" fill="Soft" /> : undefined}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Export
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{exportState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>{exportState.error.message}</b>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportKeysTile() {
|
||||
const [expand, setExpand] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Export Messages Data"
|
||||
description="Save password protected copy of encryption data on your device to decrypt messages later."
|
||||
after={
|
||||
<Box>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setExpand(!expand)}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
before={
|
||||
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
|
||||
}
|
||||
>
|
||||
<Text as="span" size="B300" truncate>
|
||||
{expand ? 'Collapse' : 'Expand'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{expand && <ExportKeys />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ImportKeysProps = {
|
||||
file: File;
|
||||
onDone?: () => void;
|
||||
};
|
||||
function ImportKeys({ file, onDone }: ImportKeysProps) {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
|
||||
const [decryptState, decryptFile] = useAsyncCallback<void, Error, [string]>(
|
||||
useCallback(
|
||||
async (password) => {
|
||||
const crypto = mx.getCrypto();
|
||||
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const keys = await decryptMegolmKeyFile(arrayBuffer, password);
|
||||
|
||||
await crypto.importRoomKeysAsJson(keys);
|
||||
},
|
||||
[file, mx]
|
||||
)
|
||||
);
|
||||
|
||||
const decrypting = decryptState.status === AsyncStatus.Loading;
|
||||
|
||||
useEffect(() => {
|
||||
if (decryptState.status === AsyncStatus.Success) {
|
||||
onDone?.();
|
||||
}
|
||||
}, [onDone, decryptState]);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (decrypting) return;
|
||||
|
||||
const { passwordInput } = evt.target as HTMLFormElement & {
|
||||
passwordInput: HTMLInputElement;
|
||||
};
|
||||
|
||||
const password = passwordInput.value;
|
||||
|
||||
if (!password) return;
|
||||
decryptFile(password).then(() => {
|
||||
if (alive()) {
|
||||
passwordInput.value = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile>
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
|
||||
<Box gap="200" alignItems="End">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">Password</Text>
|
||||
<PasswordInput
|
||||
name="passwordInput"
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
required
|
||||
autoFocus
|
||||
readOnly={decrypting}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={decrypting}
|
||||
before={decrypting ? <Spinner size="200" variant="Secondary" fill="Soft" /> : undefined}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Decrypt
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{decryptState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>{decryptState.error.message}</b>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportKeysTile() {
|
||||
const [file, setFile] = useState<File>();
|
||||
const pickFile = useFilePicker(setFile);
|
||||
|
||||
const handleDone = useCallback(() => {
|
||||
setFile(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Import Messages Data"
|
||||
description="Load password protected copy of encryption data from device to decrypt your messages."
|
||||
after={
|
||||
<Box>
|
||||
{file ? (
|
||||
<Button
|
||||
style={{ maxWidth: toRem(200) }}
|
||||
type="button"
|
||||
onClick={() => setFile(undefined)}
|
||||
size="300"
|
||||
variant="Warning"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.File} filled />}
|
||||
after={<Icon size="100" src={Icons.Cross} />}
|
||||
>
|
||||
<Text as="span" size="B300" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => pickFile('text/plain')}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.ArrowRight} />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
Import
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{file && <ImportKeys file={file} onDone={handleDone} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalBackup() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Local Backup</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<ExportKeysTile />
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<ImportKeysTile />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
187
src/app/features/settings/devices/OtherDevices.tsx
Normal file
187
src/app/features/settings/devices/OtherDevices.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA';
|
||||
import { DeviceDeleteBtn, DeviceTile } from './DeviceTile';
|
||||
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
|
||||
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
|
||||
import { VerifyOtherDeviceTile } from './Verification';
|
||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
|
||||
|
||||
type OtherDevicesProps = {
|
||||
devices: IMyDevice[];
|
||||
refreshDeviceList: () => Promise<void>;
|
||||
showVerification?: boolean;
|
||||
};
|
||||
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const crypto = mx.getCrypto();
|
||||
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleToggleDelete = useCallback((deviceId: string) => {
|
||||
setDeleted((deviceIds) => {
|
||||
const newIds = new Set(deviceIds);
|
||||
if (newIds.has(deviceId)) {
|
||||
newIds.delete(deviceId);
|
||||
} else {
|
||||
newIds.add(deviceId);
|
||||
}
|
||||
return newIds;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [deleteState, setDeleteState] = useState<AsyncState<void, MatrixError>>({
|
||||
status: AsyncStatus.Idle,
|
||||
});
|
||||
|
||||
const deleteDevices = useAsync(
|
||||
useCallback(
|
||||
async (authDict?: AuthDict) => {
|
||||
await mx.deleteMultipleDevices(Array.from(deleted), authDict);
|
||||
},
|
||||
[mx, deleted]
|
||||
),
|
||||
useCallback(
|
||||
(state: typeof deleteState) => {
|
||||
if (state.status === AsyncStatus.Success) {
|
||||
setDeleted(new Set());
|
||||
refreshDeviceList();
|
||||
}
|
||||
setDeleteState(state);
|
||||
},
|
||||
[refreshDeviceList]
|
||||
)
|
||||
);
|
||||
const [authData, deleteError] = useUIAMatrixError(
|
||||
deleteState.status === AsyncStatus.Error ? deleteState.error : undefined
|
||||
);
|
||||
const deleting = deleteState.status === AsyncStatus.Loading || authData !== undefined;
|
||||
|
||||
const handleCancelDelete = () => setDeleted(new Set());
|
||||
const handleCancelAuth = useCallback(() => {
|
||||
setDeleteState({ status: AsyncStatus.Idle });
|
||||
}, []);
|
||||
|
||||
return devices.length > 0 ? (
|
||||
<>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Others</Text>
|
||||
{devices
|
||||
.sort((d1, d2) => {
|
||||
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
||||
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
|
||||
})
|
||||
.map((device) => (
|
||||
<SequenceCard
|
||||
key={device.device_id}
|
||||
className={SequenceCardStyle}
|
||||
variant={deleted.has(device.device_id) ? 'Critical' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<DeviceTile
|
||||
device={device}
|
||||
deleted={deleted.has(device.device_id)}
|
||||
refreshDeviceList={refreshDeviceList}
|
||||
disabled={deleting}
|
||||
options={
|
||||
<DeviceDeleteBtn
|
||||
deviceId={device.device_id}
|
||||
deleted={deleted.has(device.device_id)}
|
||||
onDeleteToggle={handleToggleDelete}
|
||||
disabled={deleting}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{showVerification && crypto && (
|
||||
<DeviceVerificationStatus
|
||||
crypto={crypto}
|
||||
userId={mx.getSafeUserId()}
|
||||
deviceId={device.device_id}
|
||||
>
|
||||
{(status) =>
|
||||
status === VerificationStatus.Unverified && (
|
||||
<VerifyOtherDeviceTile crypto={crypto} deviceId={device.device_id} />
|
||||
)
|
||||
}
|
||||
</DeviceVerificationStatus>
|
||||
)}
|
||||
</SequenceCard>
|
||||
))}
|
||||
</Box>
|
||||
{deleted.size > 0 && (
|
||||
<Menu
|
||||
style={{
|
||||
position: 'sticky',
|
||||
padding: config.space.S200,
|
||||
paddingLeft: config.space.S400,
|
||||
bottom: config.space.S400,
|
||||
left: config.space.S400,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
variant="Critical"
|
||||
>
|
||||
<Box alignItems="Center" gap="400">
|
||||
<Box grow="Yes" direction="Column">
|
||||
{deleteError ? (
|
||||
<Text size="T200">
|
||||
<b>Failed to logout devices! Please try again. {deleteError.message}</b>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T200">
|
||||
<b>Logout from selected devices. ({deleted.size} selected)</b>
|
||||
</Text>
|
||||
)}
|
||||
{authData && (
|
||||
<ActionUIAFlowsLoader
|
||||
authData={authData}
|
||||
unsupported={() => (
|
||||
<Text size="T200">
|
||||
Authentication steps to perform this action are not supported by client.
|
||||
</Text>
|
||||
)}
|
||||
>
|
||||
{(ongoingFlow) => (
|
||||
<ActionUIA
|
||||
authData={authData}
|
||||
ongoingFlow={ongoingFlow}
|
||||
action={deleteDevices}
|
||||
onCancel={handleCancelAuth}
|
||||
/>
|
||||
)}
|
||||
</ActionUIAFlowsLoader>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No" gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={deleting}
|
||||
onClick={handleCancelDelete}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
radii="300"
|
||||
disabled={deleting}
|
||||
before={deleting && <Spinner variant="Critical" fill="Solid" size="100" />}
|
||||
onClick={() => deleteDevices()}
|
||||
>
|
||||
<Text size="B300">Logout</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
335
src/app/features/settings/devices/Verification.tsx
Normal file
335
src/app/features/settings/devices/Verification.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Spinner,
|
||||
Text,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
IconButton,
|
||||
RectCords,
|
||||
PopOut,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { CryptoApi, VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
|
||||
import { InfoCard } from '../../../components/info-card';
|
||||
import { ManualVerificationTile } from '../../../components/ManualVerification';
|
||||
import { SecretStorageKeyContent } from '../../../../types/matrix/accountData';
|
||||
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { DeviceVerification } from '../../../components/DeviceVerification';
|
||||
import {
|
||||
DeviceVerificationReset,
|
||||
DeviceVerificationSetup,
|
||||
} from '../../../components/DeviceVerificationSetup';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
type VerificationStatusBadgeProps = {
|
||||
verificationStatus: VerificationStatus;
|
||||
otherUnverifiedCount?: number;
|
||||
};
|
||||
export function VerificationStatusBadge({
|
||||
verificationStatus,
|
||||
otherUnverifiedCount,
|
||||
}: VerificationStatusBadgeProps) {
|
||||
if (
|
||||
verificationStatus === VerificationStatus.Unknown ||
|
||||
typeof otherUnverifiedCount !== 'number'
|
||||
) {
|
||||
return <Spinner size="400" variant="Secondary" />;
|
||||
}
|
||||
if (verificationStatus === VerificationStatus.Unverified) {
|
||||
return (
|
||||
<Badge variant="Critical" fill="Solid" size="500">
|
||||
<Text size="L400">Unverified</Text>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (otherUnverifiedCount > 0) {
|
||||
return (
|
||||
<Badge variant="Warning" fill="Solid" size="500">
|
||||
<Text size="L400">{otherUnverifiedCount} Unverified</Text>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="Success" fill="Solid" size="500">
|
||||
<Text size="L400">Verified</Text>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function LearnStartVerificationFromOtherDevice() {
|
||||
return (
|
||||
<Box direction="Column">
|
||||
<Text size="T200">Steps to verify from other device.</Text>
|
||||
<Text as="div" size="T200">
|
||||
<ul style={{ margin: `${config.space.S100} 0` }}>
|
||||
<li>Open your other verified device.</li>
|
||||
<li>
|
||||
Open <i>Settings</i>.
|
||||
</li>
|
||||
<li>
|
||||
Find this device in <i>Devices/Sessions</i> section.
|
||||
</li>
|
||||
<li>Initiate verification.</li>
|
||||
</ul>
|
||||
</Text>
|
||||
<Text size="T200">
|
||||
If you do not have any verified device press the <i>"Verify Manually"</i> button.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type VerifyCurrentDeviceTileProps = {
|
||||
secretStorageKeyId: string;
|
||||
secretStorageKeyContent: SecretStorageKeyContent;
|
||||
};
|
||||
export function VerifyCurrentDeviceTile({
|
||||
secretStorageKeyId,
|
||||
secretStorageKeyContent,
|
||||
}: VerifyCurrentDeviceTileProps) {
|
||||
const [learnMore, setLearnMore] = useState(false);
|
||||
|
||||
const [manualVerification, setManualVerification] = useState(false);
|
||||
const handleCancelVerification = () => setManualVerification(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoCard
|
||||
variant="Critical"
|
||||
title="Unverified"
|
||||
description={
|
||||
<>
|
||||
Start verification from other device or verify manually.{' '}
|
||||
<Text as="a" size="T200" onClick={() => setLearnMore(!learnMore)}>
|
||||
<b>{learnMore ? 'View Less' : 'Learn More'}</b>
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
after={
|
||||
!manualVerification && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => setManualVerification(true)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
Verify Manually
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{learnMore && <LearnStartVerificationFromOtherDevice />}
|
||||
</InfoCard>
|
||||
{manualVerification && (
|
||||
<ManualVerificationTile
|
||||
secretStorageKeyId={secretStorageKeyId}
|
||||
secretStorageKeyContent={secretStorageKeyContent}
|
||||
options={
|
||||
<Chip
|
||||
type="button"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
onClick={handleCancelVerification}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</Chip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type VerifyOtherDeviceTileProps = {
|
||||
crypto: CryptoApi;
|
||||
deviceId: string;
|
||||
};
|
||||
export function VerifyOtherDeviceTile({ crypto, deviceId }: VerifyOtherDeviceTileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [requestState, setRequestState] = useState<AsyncState<VerificationRequest, Error>>({
|
||||
status: AsyncStatus.Idle,
|
||||
});
|
||||
|
||||
const requestVerification = useAsync<VerificationRequest, Error, []>(
|
||||
useCallback(() => {
|
||||
const requestPromise = crypto.requestDeviceVerification(mx.getSafeUserId(), deviceId);
|
||||
return requestPromise;
|
||||
}, [mx, crypto, deviceId]),
|
||||
setRequestState
|
||||
);
|
||||
|
||||
const handleExit = useCallback(() => {
|
||||
setRequestState({
|
||||
status: AsyncStatus.Idle,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const requesting = requestState.status === AsyncStatus.Loading;
|
||||
return (
|
||||
<InfoCard
|
||||
variant="Warning"
|
||||
title="Unverified"
|
||||
description="Verify device identity and grant access to encrypted messages."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
variant="Warning"
|
||||
radii="300"
|
||||
onClick={requestVerification}
|
||||
before={requesting && <Spinner size="100" variant="Warning" fill="Solid" />}
|
||||
disabled={requesting}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
Verify
|
||||
</Text>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{requestState.status === AsyncStatus.Error && (
|
||||
<Text size="T200">{requestState.error.message}</Text>
|
||||
)}
|
||||
{requestState.status === AsyncStatus.Success && (
|
||||
<DeviceVerification request={requestState.data} onExit={handleExit} />
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
type EnableVerificationProps = {
|
||||
visible: boolean;
|
||||
};
|
||||
export function EnableVerification({ visible }: EnableVerificationProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleCancel = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible && (
|
||||
<Button size="300" radii="300" onClick={() => setOpen(true)}>
|
||||
<Text as="span" size="B300">
|
||||
Enable
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
{open && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<DeviceVerificationSetup onCancel={handleCancel} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceVerificationOptions() {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
|
||||
const [reset, setReset] = useState(false);
|
||||
|
||||
const handleCancelReset = useCallback(() => {
|
||||
setReset(false);
|
||||
}, []);
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||
setMenuCords(event.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setMenuCords(undefined);
|
||||
setReset(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-pressed={!!menuCords}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Icon size="100" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
variant="Critical"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
radii="300"
|
||||
fill="None"
|
||||
>
|
||||
<Text as="span" size="T300" truncate>
|
||||
Reset
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{reset && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<DeviceVerificationReset onCancel={handleCancelReset} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/app/features/settings/devices/index.ts
Normal file
1
src/app/features/settings/devices/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Devices';
|
||||
51
src/app/features/settings/emojis-stickers/EmojisStickers.tsx
Normal file
51
src/app/features/settings/emojis-stickers/EmojisStickers.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { GlobalPacks } from './GlobalPacks';
|
||||
import { UserPack } from './UserPack';
|
||||
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||
import { ImagePackView } from '../../../components/image-pack-view';
|
||||
|
||||
type EmojisStickersProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
||||
const [imagePack, setImagePack] = useState<ImagePack>();
|
||||
|
||||
const handleImagePackViewClose = () => {
|
||||
setImagePack(undefined);
|
||||
};
|
||||
|
||||
if (imagePack) {
|
||||
return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Emojis & Stickers
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<UserPack onViewPack={setImagePack} />
|
||||
<GlobalPacks onViewPack={setImagePack} />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
488
src/app/features/settings/emojis-stickers/GlobalPacks.tsx
Normal file
488
src/app/features/settings/emojis-stickers/GlobalPacks.tsx
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
IconButton,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
config,
|
||||
Spinner,
|
||||
Menu,
|
||||
RectCords,
|
||||
PopOut,
|
||||
Checkbox,
|
||||
toRem,
|
||||
Scroll,
|
||||
Header,
|
||||
Line,
|
||||
Chip,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useGlobalImagePacks, useRoomsImagePacks } from '../../../hooks/useImagePacks';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
EmoteRoomsContent,
|
||||
ImagePack,
|
||||
ImageUsage,
|
||||
PackAddress,
|
||||
packAddressEqual,
|
||||
} from '../../../plugins/custom-emoji';
|
||||
import { LineClamp2 } from '../../../styles/Text.css';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
function GlobalPackSelector({
|
||||
packs,
|
||||
useAuthentication,
|
||||
onSelect,
|
||||
}: {
|
||||
packs: ImagePack[];
|
||||
useAuthentication: boolean;
|
||||
onSelect: (addresses: PackAddress[]) => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const roomToPacks = useMemo(() => {
|
||||
const rToP = new Map<string, ImagePack[]>();
|
||||
packs
|
||||
.filter((pack) => !pack.deleted)
|
||||
.forEach((pack) => {
|
||||
if (!pack.address) return;
|
||||
const pks = rToP.get(pack.address.roomId) ?? [];
|
||||
pks.push(pack);
|
||||
rToP.set(pack.address.roomId, pks);
|
||||
});
|
||||
return rToP;
|
||||
}, [packs]);
|
||||
|
||||
const [selected, setSelected] = useState<PackAddress[]>([]);
|
||||
const toggleSelect = (address: PackAddress) => {
|
||||
setSelected((addresses) => {
|
||||
const newAddresses = addresses.filter((addr) => !packAddressEqual(addr, address));
|
||||
if (newAddresses.length !== addresses.length) {
|
||||
return newAddresses;
|
||||
}
|
||||
newAddresses.push(address);
|
||||
return newAddresses;
|
||||
});
|
||||
};
|
||||
|
||||
const hasSelected = selected.length > 0;
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Header size="400" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
|
||||
<Box grow="Yes">
|
||||
<Text size="L400" truncate>
|
||||
Room Packs
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<Chip
|
||||
radii="Pill"
|
||||
variant={hasSelected ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={hasSelected}
|
||||
onClick={() => onSelect(selected)}
|
||||
>
|
||||
<Text size="B300">{hasSelected ? 'Save' : 'Close'}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Header>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box grow="Yes">
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="400"
|
||||
style={{
|
||||
paddingLeft: config.space.S300,
|
||||
paddingTop: config.space.S300,
|
||||
paddingBottom: config.space.S300,
|
||||
paddingRight: config.space.S100,
|
||||
}}
|
||||
>
|
||||
{Array.from(roomToPacks.entries()).map(([roomId, roomPacks]) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<Box key={roomId} direction="Column" gap="100">
|
||||
<Text size="L400">{room.name}</Text>
|
||||
{roomPacks.map((pack) => {
|
||||
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication)
|
||||
: undefined;
|
||||
const { address } = pack;
|
||||
if (!address) return null;
|
||||
|
||||
const added = selected.find((addr) => packAddressEqual(addr, address));
|
||||
return (
|
||||
<SequenceCard
|
||||
key={pack.id}
|
||||
className={SequenceCardStyle}
|
||||
variant={added ? 'Success' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={pack.meta.name ?? 'Unknown'}
|
||||
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
|
||||
before={
|
||||
<Box alignItems="Center" gap="300">
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</Box>
|
||||
}
|
||||
after={
|
||||
<Checkbox variant="Success" onClick={() => toggleSelect(address)} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{roomToPacks.size === 0 && (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<Box
|
||||
justifyContent="Center"
|
||||
direction="Column"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${config.space.S700} ${config.space.S400}`,
|
||||
maxWidth: toRem(300),
|
||||
margin: 'auto',
|
||||
}}
|
||||
>
|
||||
<Text size="H5" align="Center">
|
||||
No Packs
|
||||
</Text>
|
||||
<Text size="T200" align="Center">
|
||||
Pack from rooms will appear here. You do not have any room with packs yet.
|
||||
</Text>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type GlobalPacksProps = {
|
||||
onViewPack: (imagePack: ImagePack) => void;
|
||||
};
|
||||
export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const globalPacks = useGlobalImagePacks();
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
|
||||
const roomIds = useAtomValue(allRoomsAtom);
|
||||
const rooms = useMemo(() => {
|
||||
const rs: Room[] = [];
|
||||
roomIds.forEach((rId) => {
|
||||
const r = mx.getRoom(rId);
|
||||
if (r) rs.push(r);
|
||||
});
|
||||
return rs;
|
||||
}, [mx, roomIds]);
|
||||
const roomsImagePack = useRoomsImagePacks(rooms);
|
||||
const nonGlobalPacks = useMemo(
|
||||
() =>
|
||||
roomsImagePack.filter(
|
||||
(pack) => !globalPacks.find((p) => packAddressEqual(pack.address, p.address))
|
||||
),
|
||||
[roomsImagePack, globalPacks]
|
||||
);
|
||||
|
||||
const [selectedPacks, setSelectedPacks] = useState<PackAddress[]>([]);
|
||||
const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
|
||||
|
||||
const unselectedGlobalPacks = useMemo(
|
||||
() =>
|
||||
nonGlobalPacks.filter(
|
||||
(pack) => !selectedPacks.find((addr) => packAddressEqual(pack.address, addr))
|
||||
),
|
||||
[selectedPacks, nonGlobalPacks]
|
||||
);
|
||||
|
||||
const handleRemove = (address: PackAddress) => {
|
||||
setRemovedPacks((addresses) => [...addresses, address]);
|
||||
};
|
||||
|
||||
const handleUndoRemove = (address: PackAddress) => {
|
||||
setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
|
||||
};
|
||||
|
||||
const handleSelected = (addresses: PackAddress[]) => {
|
||||
setMenuCords(undefined);
|
||||
if (addresses.length > 0) {
|
||||
setSelectedPacks((a) => [...addresses, ...a]);
|
||||
}
|
||||
};
|
||||
|
||||
const [applyState, applyChanges] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const content =
|
||||
mx.getAccountData(AccountDataEvent.PoniesEmoteRooms)?.getContent<EmoteRoomsContent>() ?? {};
|
||||
const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content));
|
||||
|
||||
selectedPacks.forEach((addr) => {
|
||||
const roomsToState = updatedContent.rooms ?? {};
|
||||
const stateKeyToObj = roomsToState[addr.roomId] ?? {};
|
||||
stateKeyToObj[addr.stateKey] = {};
|
||||
roomsToState[addr.roomId] = stateKeyToObj;
|
||||
updatedContent.rooms = roomsToState;
|
||||
});
|
||||
|
||||
removedPacks.forEach((addr) => {
|
||||
if (updatedContent.rooms?.[addr.roomId]?.[addr.stateKey]) {
|
||||
delete updatedContent.rooms?.[addr.roomId][addr.stateKey];
|
||||
}
|
||||
});
|
||||
|
||||
await mx.setAccountData(AccountDataEvent.PoniesEmoteRooms, updatedContent);
|
||||
}, [mx, selectedPacks, removedPacks])
|
||||
);
|
||||
|
||||
const resetChanges = useCallback(() => {
|
||||
setSelectedPacks([]);
|
||||
setRemovedPacks([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (applyState.status === AsyncStatus.Success) {
|
||||
resetChanges();
|
||||
}
|
||||
}, [applyState, resetChanges]);
|
||||
|
||||
const handleSelectMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const applyingChanges = applyState.status === AsyncStatus.Loading;
|
||||
const hasChanges = removedPacks.length > 0 || selectedPacks.length > 0;
|
||||
|
||||
const renderPack = (pack: ImagePack) => {
|
||||
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
|
||||
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||
const { address } = pack;
|
||||
if (!address) return null;
|
||||
const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
key={pack.id}
|
||||
className={SequenceCardStyle}
|
||||
variant={removed ? 'Critical' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={
|
||||
<span style={{ textDecoration: removed ? 'line-through' : undefined }}>
|
||||
{pack.meta.name ?? 'Unknown'}
|
||||
</span>
|
||||
}
|
||||
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
|
||||
before={
|
||||
<Box alignItems="Center" gap="300">
|
||||
{removed ? (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Critical"
|
||||
onClick={() => handleUndoRemove(address)}
|
||||
disabled={applyingChanges}
|
||||
>
|
||||
<Icon src={Icons.Plus} size="100" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Secondary"
|
||||
onClick={() => handleRemove(address)}
|
||||
disabled={applyingChanges}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</Box>
|
||||
}
|
||||
after={
|
||||
!removed && (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => onViewPack(pack)}
|
||||
>
|
||||
<Text size="B300">View</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Favorite Packs</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select Pack"
|
||||
description="Pick emojis and stickers pack from rooms to use in all rooms."
|
||||
after={
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSelectMenu}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">Select</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
style={{
|
||||
display: 'flex',
|
||||
maxWidth: toRem(400),
|
||||
width: '100vw',
|
||||
maxHeight: toRem(500),
|
||||
}}
|
||||
>
|
||||
<GlobalPackSelector
|
||||
packs={unselectedGlobalPacks}
|
||||
useAuthentication={useAuthentication}
|
||||
onSelect={handleSelected}
|
||||
/>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{globalPacks.map(renderPack)}
|
||||
{nonGlobalPacks
|
||||
.filter((pack) => !!selectedPacks.find((addr) => packAddressEqual(pack.address, addr)))
|
||||
.map(renderPack)}
|
||||
</Box>
|
||||
{hasChanges && (
|
||||
<Menu
|
||||
style={{
|
||||
position: 'sticky',
|
||||
padding: config.space.S200,
|
||||
paddingLeft: config.space.S400,
|
||||
bottom: config.space.S400,
|
||||
left: config.space.S400,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
variant="Success"
|
||||
>
|
||||
<Box alignItems="Center" gap="400">
|
||||
<Box grow="Yes" direction="Column">
|
||||
{applyState.status === AsyncStatus.Error ? (
|
||||
<Text size="T200">
|
||||
<b>Failed to apply changes! Please try again.</b>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T200">
|
||||
<b>Changes saved! Apply when ready.</b>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No" gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={applyingChanges}
|
||||
onClick={resetChanges}
|
||||
>
|
||||
<Text size="B300">Reset</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
disabled={applyingChanges}
|
||||
before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
|
||||
onClick={applyChanges}
|
||||
>
|
||||
<Text size="B300">Apply Changes</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
src/app/features/settings/emojis-stickers/UserPack.tsx
Normal file
71
src/app/features/settings/emojis-stickers/UserPack.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import React from 'react';
|
||||
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Icon, Icons, Text } from 'folds';
|
||||
import { useUserImagePack } from '../../../hooks/useImagePacks';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
|
||||
type UserPackProps = {
|
||||
onViewPack: (imagePack: ImagePack) => void;
|
||||
};
|
||||
export function UserPack({ onViewPack }: UserPackProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const userPack = useUserImagePack();
|
||||
const avatarMxc = userPack?.getAvatarUrl(ImageUsage.Emoticon);
|
||||
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||
|
||||
const handleView = () => {
|
||||
if (userPack) {
|
||||
onViewPack(userPack);
|
||||
} else {
|
||||
const defaultPack = new ImagePack(mx.getUserId() ?? '', {}, undefined);
|
||||
onViewPack(defaultPack);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Default Pack</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={userPack?.meta.name ?? 'Unknown'}
|
||||
description={userPack?.meta.attribution}
|
||||
before={
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
after={
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={handleView}
|
||||
>
|
||||
<Text size="B300">View</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/features/settings/emojis-stickers/index.ts
Normal file
1
src/app/features/settings/emojis-stickers/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './EmojisStickers';
|
||||
618
src/app/features/settings/general/General.tsx
Normal file
618
src/app/features/settings/general/General.tsx
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
import React, {
|
||||
ChangeEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
as,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Switch,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { KeySymbol } from '../../../utils/key-symbol';
|
||||
import { isMacOS } from '../../../utils/user-agent';
|
||||
import {
|
||||
DarkTheme,
|
||||
LightTheme,
|
||||
Theme,
|
||||
ThemeKind,
|
||||
useSystemThemeKind,
|
||||
useThemeNames,
|
||||
useThemes,
|
||||
} from '../../../hooks/useTheme';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
themeNames: Record<string, string>;
|
||||
themes: Theme[];
|
||||
selected: Theme;
|
||||
onSelect: (theme: Theme) => void;
|
||||
};
|
||||
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||||
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
||||
<Menu {...props} ref={ref}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{themes.map((theme) => (
|
||||
<MenuItem
|
||||
key={theme.id}
|
||||
size="300"
|
||||
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => onSelect(theme)}
|
||||
>
|
||||
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
)
|
||||
);
|
||||
|
||||
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
const themes = useThemes();
|
||||
const themeNames = useThemeNames();
|
||||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||||
|
||||
const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleThemeSelect = (theme: Theme) => {
|
||||
setThemeId(theme.id);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={disabled ? undefined : handleThemeMenu}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<Text size="T300">{themeNames[selectedTheme.id] ?? selectedTheme.id}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<ThemeSelector
|
||||
themeNames={themeNames}
|
||||
themes={themes}
|
||||
selected={selectedTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemThemePreferences() {
|
||||
const themeKind = useSystemThemeKind();
|
||||
const themeNames = useThemeNames();
|
||||
const themes = useThemes();
|
||||
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
|
||||
const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light);
|
||||
const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark);
|
||||
|
||||
const selectedLightTheme = lightThemes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
|
||||
const selectedDarkTheme = darkThemes.find((theme) => theme.id === darkThemeId) ?? DarkTheme;
|
||||
|
||||
const [ltCords, setLTCords] = useState<RectCords>();
|
||||
const [dtCords, setDTCords] = useState<RectCords>();
|
||||
|
||||
const handleLightThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setLTCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
const handleDarkThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setDTCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleLightThemeSelect = (theme: Theme) => {
|
||||
setLightThemeId(theme.id);
|
||||
setLTCords(undefined);
|
||||
};
|
||||
|
||||
const handleDarkThemeSelect = (theme: Theme) => {
|
||||
setDarkThemeId(theme.id);
|
||||
setDTCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box wrap="Wrap" gap="400">
|
||||
<SettingTile
|
||||
title="Light Theme:"
|
||||
after={
|
||||
<Chip
|
||||
variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
|
||||
outlined={themeKind === ThemeKind.Light}
|
||||
radii="Pill"
|
||||
after={<Icon size="200" src={Icons.ChevronBottom} />}
|
||||
onClick={handleLightThemeMenu}
|
||||
>
|
||||
<Text size="B300">{themeNames[selectedLightTheme.id] ?? selectedLightTheme.id}</Text>
|
||||
</Chip>
|
||||
}
|
||||
/>
|
||||
<PopOut
|
||||
anchor={ltCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setLTCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<ThemeSelector
|
||||
themeNames={themeNames}
|
||||
themes={lightThemes}
|
||||
selected={selectedLightTheme}
|
||||
onSelect={handleLightThemeSelect}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Dark Theme:"
|
||||
after={
|
||||
<Chip
|
||||
variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
|
||||
outlined={themeKind === ThemeKind.Dark}
|
||||
radii="Pill"
|
||||
after={<Icon size="200" src={Icons.ChevronBottom} />}
|
||||
onClick={handleDarkThemeMenu}
|
||||
>
|
||||
<Text size="B300">{themeNames[selectedDarkTheme.id] ?? selectedDarkTheme.id}</Text>
|
||||
</Chip>
|
||||
}
|
||||
/>
|
||||
<PopOut
|
||||
anchor={dtCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setDTCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<ThemeSelector
|
||||
themeNames={themeNames}
|
||||
themes={darkThemes}
|
||||
selected={selectedDarkTheme}
|
||||
onSelect={handleDarkThemeSelect}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PageZoomInput() {
|
||||
const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom');
|
||||
const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`);
|
||||
|
||||
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
setCurrentZoom(evt.target.value);
|
||||
};
|
||||
|
||||
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.stopPropagation();
|
||||
setCurrentZoom(pageZoom.toString());
|
||||
}
|
||||
if (
|
||||
isKeyHotkey('enter', evt) &&
|
||||
'value' in evt.target &&
|
||||
typeof evt.target.value === 'string'
|
||||
) {
|
||||
const newZoom = parseInt(evt.target.value, 10);
|
||||
if (Number.isNaN(newZoom)) return;
|
||||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||||
setPageZoom(safeZoom);
|
||||
setCurrentZoom(safeZoom.toString());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
style={{ width: toRem(100) }}
|
||||
variant={pageZoom === parseInt(currentZoom, 10) ? 'Secondary' : 'Success'}
|
||||
size="300"
|
||||
radii="300"
|
||||
type="number"
|
||||
min="75"
|
||||
max="150"
|
||||
value={currentZoom}
|
||||
onChange={handleZoomChange}
|
||||
onKeyDown={handleZoomEnter}
|
||||
after={<Text size="T300">%</Text>}
|
||||
outlined
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Appearance() {
|
||||
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Appearance</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="System Theme"
|
||||
description="Choose between light and dark theme based on system preference."
|
||||
after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
|
||||
/>
|
||||
{systemTheme && <SystemThemePreferences />}
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Theme"
|
||||
description="Theme to use when system theme is not enabled."
|
||||
after={<SelectTheme disabled={systemTheme} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Twitter Emoji"
|
||||
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Editor() {
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Editor</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="ENTER for Newline"
|
||||
description={`Use ${
|
||||
isMacOS() ? KeySymbol.Command : 'Ctrl'
|
||||
} + ENTER to send message and ENTER for newline.`}
|
||||
after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Markdown Formatting"
|
||||
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectMessageLayout() {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
const messageLayoutItems = useMessageLayoutItems();
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleSelect = (layout: MessageLayout) => {
|
||||
setMessageLayout(layout);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Text size="T300">
|
||||
{messageLayoutItems.find((i) => i.layout === messageLayout)?.name ?? messageLayout}
|
||||
</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{messageLayoutItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.layout}
|
||||
size="300"
|
||||
variant={messageLayout === item.layout ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(item.layout)}
|
||||
>
|
||||
<Text size="T300">{item.name}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectMessageSpacing() {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const messageSpacingItems = useMessageSpacingItems();
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleSelect = (layout: MessageSpacing) => {
|
||||
setMessageSpacing(layout);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Text size="T300">
|
||||
{messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
|
||||
</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{messageSpacingItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.spacing}
|
||||
size="300"
|
||||
variant={messageSpacing === item.spacing ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(item.spacing)}
|
||||
>
|
||||
<Text size="T300">{item.name}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Messages() {
|
||||
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
||||
settingsAtom,
|
||||
'hideMembershipEvents'
|
||||
);
|
||||
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(
|
||||
settingsAtom,
|
||||
'hideNickAvatarEvents'
|
||||
);
|
||||
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Messages</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Message Layout" after={<SelectMessageLayout />} />
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Membership Change"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={hideMembershipEvents}
|
||||
onChange={setHideMembershipEvents}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Profile Change"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={hideNickAvatarEvents}
|
||||
onChange={setHideNickAvatarEvents}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Disable Media Auto Load"
|
||||
after={<Switch variant="Primary" value={mediaAutoLoad} onChange={setMediaAutoLoad} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Url Preview"
|
||||
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Url Preview in Encrypted Room"
|
||||
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Hidden Events"
|
||||
after={
|
||||
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type GeneralProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function General({ requestClose }: GeneralProps) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
General
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Appearance />
|
||||
<Editor />
|
||||
<Messages />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
1
src/app/features/settings/general/index.ts
Normal file
1
src/app/features/settings/general/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './General';
|
||||
1
src/app/features/settings/index.ts
Normal file
1
src/app/features/settings/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Settings';
|
||||
152
src/app/features/settings/notifications/AllMessages.tsx
Normal file
152
src/app/features/settings/notifications/AllMessages.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Badge, Box, Text } from 'folds';
|
||||
import { ConditionKind, IPushRules, PushRuleCondition, PushRuleKind, RuleId } from 'matrix-js-sdk';
|
||||
import { useAccountData } from '../../../hooks/useAccountData';
|
||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { PushRuleData, usePushRule } from '../../../hooks/usePushRule';
|
||||
import {
|
||||
getNotificationModeActions,
|
||||
NotificationMode,
|
||||
useNotificationModeActions,
|
||||
} from '../../../hooks/useNotificationMode';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
const getAllMessageDefaultRule = (
|
||||
ruleId: RuleId,
|
||||
encrypted: boolean,
|
||||
oneToOne: boolean
|
||||
): PushRuleData => {
|
||||
const conditions: PushRuleCondition[] = [];
|
||||
if (oneToOne)
|
||||
conditions.push({
|
||||
kind: ConditionKind.RoomMemberCount,
|
||||
is: '2',
|
||||
});
|
||||
conditions.push({
|
||||
kind: ConditionKind.EventMatch,
|
||||
key: 'type',
|
||||
pattern: encrypted ? 'm.room.encrypted' : 'm.room.message',
|
||||
});
|
||||
|
||||
return {
|
||||
kind: PushRuleKind.Underride,
|
||||
pushRule: {
|
||||
rule_id: ruleId,
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions,
|
||||
actions: getNotificationModeActions(NotificationMode.NotifyLoud),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type PushRulesProps = {
|
||||
ruleId: RuleId.DM | RuleId.EncryptedDM | RuleId.Message | RuleId.EncryptedMessage;
|
||||
pushRules: IPushRules;
|
||||
encrypted?: boolean;
|
||||
oneToOne?: boolean;
|
||||
};
|
||||
function AllMessagesModeSwitcher({
|
||||
ruleId,
|
||||
pushRules,
|
||||
encrypted = false,
|
||||
oneToOne = false,
|
||||
}: PushRulesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const defaultPushRuleData = getAllMessageDefaultRule(ruleId, encrypted, oneToOne);
|
||||
const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
|
||||
const getModeActions = useNotificationModeActions();
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (mode: NotificationMode) => {
|
||||
const actions = getModeActions(mode);
|
||||
await mx.setPushRuleActions('global', kind, ruleId, actions);
|
||||
},
|
||||
[mx, getModeActions, kind, ruleId]
|
||||
);
|
||||
|
||||
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
export function AllMessagesNotifications() {
|
||||
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
|
||||
const pushRules = useMemo(
|
||||
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
|
||||
[pushRulesEvt]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">All Messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Secondary" fill="Solid">
|
||||
<Text size="L400">1</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="1-to-1 Chats"
|
||||
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="1-to-1 Chats (Encrypted)"
|
||||
after={
|
||||
<AllMessagesModeSwitcher
|
||||
pushRules={pushRules}
|
||||
ruleId={RuleId.EncryptedDM}
|
||||
encrypted
|
||||
oneToOne
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Rooms"
|
||||
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.Message} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Rooms (Encrypted)"
|
||||
after={
|
||||
<AllMessagesModeSwitcher
|
||||
pushRules={pushRules}
|
||||
ruleId={RuleId.EncryptedMessage}
|
||||
encrypted
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
171
src/app/features/settings/notifications/IgnoredUserList.tsx
Normal file
171
src/app/features/settings/notifications/IgnoredUserList.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds';
|
||||
import { useAccountData } from '../../../hooks/useAccountData';
|
||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { isUserId } from '../../../utils/matrix';
|
||||
|
||||
type IgnoredUserListContent = {
|
||||
ignored_users?: Record<string, object>;
|
||||
};
|
||||
|
||||
function IgnoreUserInput({ userList }: { userList: string[] }) {
|
||||
const mx = useMatrixClient();
|
||||
const [userId, setUserId] = useState<string>('');
|
||||
|
||||
const [ignoreState, ignore] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (uId: string) => {
|
||||
mx.setIgnoredUsers([...userList, uId]);
|
||||
setUserId('');
|
||||
},
|
||||
[mx, userList]
|
||||
)
|
||||
);
|
||||
const ignoring = ignoreState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const uId = evt.currentTarget.value;
|
||||
setUserId(uId);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setUserId('');
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (ignoring) return;
|
||||
|
||||
const target = evt.target as HTMLFormElement | undefined;
|
||||
const userIdInput = target?.userIdInput as HTMLInputElement | undefined;
|
||||
const uId = userIdInput?.value.trim();
|
||||
if (!uId) return;
|
||||
|
||||
if (!isUserId(uId)) return;
|
||||
|
||||
ignore(uId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={ignoring}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
required
|
||||
name="userIdInput"
|
||||
value={userId}
|
||||
onChange={handleChange}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
style={{ paddingRight: config.space.S200 }}
|
||||
readOnly={ignoring}
|
||||
after={
|
||||
userId &&
|
||||
!ignoring && (
|
||||
<IconButton
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
type="submit"
|
||||
disabled={ignoring}
|
||||
>
|
||||
{ignoring && <Spinner variant="Secondary" size="300" />}
|
||||
<Text size="B400">Block</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function IgnoredUserChip({ userId, userList }: { userId: string; userList: string[] }) {
|
||||
const mx = useMatrixClient();
|
||||
const [unignoreState, unignore] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => mx.setIgnoredUsers(userList.filter((uId) => uId !== userId)),
|
||||
[mx, userId, userList]
|
||||
)
|
||||
);
|
||||
|
||||
const handleUnignore = () => unignore();
|
||||
|
||||
const unIgnoring = unignoreState.status === AsyncStatus.Loading;
|
||||
return (
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
after={
|
||||
unIgnoring ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
) : (
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
)
|
||||
}
|
||||
onClick={handleUnignore}
|
||||
disabled={unIgnoring}
|
||||
>
|
||||
<Text size="T200" truncate>
|
||||
{userId}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
export function IgnoredUserList() {
|
||||
const ignoredUserListEvt = useAccountData(AccountDataEvent.IgnoredUserList);
|
||||
const ignoredUsers = useMemo(() => {
|
||||
const ignoredUsersRecord =
|
||||
ignoredUserListEvt?.getContent<IgnoredUserListContent>().ignored_users ?? {};
|
||||
return Object.keys(ignoredUsersRecord);
|
||||
}, [ignoredUserListEvt]);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Block Messages</Text>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select User"
|
||||
description="Prevent receiving message by adding userId into blocklist."
|
||||
>
|
||||
<Box direction="Column" gap="300">
|
||||
<IgnoreUserInput userList={ignoredUsers} />
|
||||
{ignoredUsers.length > 0 && (
|
||||
<Box direction="Inherit" gap="100">
|
||||
<Text size="L400">Blocklist</Text>
|
||||
<Box wrap="Wrap" gap="200">
|
||||
{ignoredUsers.map((userId) => (
|
||||
<IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
203
src/app/features/settings/notifications/KeywordMessages.tsx
Normal file
203
src/app/features/settings/notifications/KeywordMessages.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
||||
import { Box, Text, Badge, Button, Input, config, IconButton, Icons, Icon, Spinner } from 'folds';
|
||||
import { useAccountData } from '../../../hooks/useAccountData';
|
||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
getNotificationModeActions,
|
||||
NotificationMode,
|
||||
NotificationModeOptions,
|
||||
useNotificationModeActions,
|
||||
} from '../../../hooks/useNotificationMode';
|
||||
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
|
||||
const NOTIFY_MODE_OPS: NotificationModeOptions = {
|
||||
highlight: true,
|
||||
};
|
||||
|
||||
function KeywordInput() {
|
||||
const mx = useMatrixClient();
|
||||
const [keyword, setKeyword] = useState<string>('');
|
||||
|
||||
const [keywordState, addKeyword] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (k: string) => {
|
||||
mx.addPushRule('global', PushRuleKind.ContentSpecific, k, {
|
||||
actions: getNotificationModeActions(NotificationMode.Notify, NOTIFY_MODE_OPS),
|
||||
pattern: k,
|
||||
});
|
||||
setKeyword('');
|
||||
},
|
||||
[mx]
|
||||
)
|
||||
);
|
||||
const addingKeyword = keywordState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const k = evt.currentTarget.value;
|
||||
setKeyword(k);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setKeyword('');
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (addingKeyword) return;
|
||||
|
||||
const target = evt.target as HTMLFormElement | undefined;
|
||||
const keywordInput = target?.keywordInput as HTMLInputElement | undefined;
|
||||
const k = keywordInput?.value.trim();
|
||||
if (!k) return;
|
||||
|
||||
addKeyword(k);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" aria-disabled={addingKeyword}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
required
|
||||
name="keywordInput"
|
||||
value={keyword}
|
||||
onChange={handleChange}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
style={{ paddingRight: config.space.S200 }}
|
||||
readOnly={addingKeyword}
|
||||
after={
|
||||
keyword &&
|
||||
!addingKeyword && (
|
||||
<IconButton
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
type="submit"
|
||||
disabled={addingKeyword}
|
||||
>
|
||||
{addingKeyword && <Spinner variant="Secondary" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type PushRulesProps = {
|
||||
pushRule: IPushRule;
|
||||
};
|
||||
|
||||
function KeywordCross({ pushRule }: PushRulesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [removeState, remove] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => mx.deletePushRule('global', PushRuleKind.ContentSpecific, pushRule.rule_id),
|
||||
[mx, pushRule]
|
||||
)
|
||||
);
|
||||
|
||||
const removing = removeState.status === AsyncStatus.Loading;
|
||||
return (
|
||||
<IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing}>
|
||||
{removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordModeSwitcher({ pushRule }: PushRulesProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (mode: NotificationMode) => {
|
||||
const actions = getModeActions(mode);
|
||||
await mx.setPushRuleActions(
|
||||
'global',
|
||||
PushRuleKind.ContentSpecific,
|
||||
pushRule.rule_id,
|
||||
actions
|
||||
);
|
||||
},
|
||||
[mx, getModeActions, pushRule]
|
||||
);
|
||||
|
||||
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
export function KeywordMessagesNotifications() {
|
||||
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
|
||||
const pushRules = useMemo(
|
||||
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
|
||||
[pushRulesEvt]
|
||||
);
|
||||
|
||||
const keywordPushRules = useMemo(() => {
|
||||
const content = pushRules.global.content ?? [];
|
||||
return content.filter(
|
||||
(pushRule) => pushRule.default === false && typeof pushRule.pattern === 'string'
|
||||
);
|
||||
}, [pushRules]);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Keyword Messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Success" fill="Solid">
|
||||
<Text size="L400">1</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select Keyword"
|
||||
description="Set a notification preference for message containing given keyword."
|
||||
>
|
||||
<KeywordInput />
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
{keywordPushRules.map((pushRule) => (
|
||||
<SequenceCard
|
||||
key={pushRule.rule_id}
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={`"${pushRule.pattern}"`}
|
||||
before={<KeywordCross pushRule={pushRule} />}
|
||||
after={<KeywordModeSwitcher pushRule={pushRule} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { IPushRule } from 'matrix-js-sdk';
|
||||
import React, { MouseEventHandler, useMemo, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { NotificationMode, useNotificationActionsMode } from '../../../hooks/useNotificationMode';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
|
||||
export const useNotificationModes = (): NotificationMode[] =>
|
||||
useMemo(() => [NotificationMode.NotifyLoud, NotificationMode.Notify, NotificationMode.OFF], []);
|
||||
|
||||
const useNotificationModeStr = (): Record<NotificationMode, string> =>
|
||||
useMemo(
|
||||
() => ({
|
||||
[NotificationMode.OFF]: 'Disable',
|
||||
[NotificationMode.Notify]: 'Notify Silent',
|
||||
[NotificationMode.NotifyLoud]: 'Notify Loud',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
type NotificationModeSwitcherProps = {
|
||||
pushRule: IPushRule;
|
||||
onChange: (mode: NotificationMode) => Promise<void>;
|
||||
};
|
||||
export function NotificationModeSwitcher({ pushRule, onChange }: NotificationModeSwitcherProps) {
|
||||
const modes = useNotificationModes();
|
||||
const modeToStr = useNotificationModeStr();
|
||||
const selectedMode = useNotificationActionsMode(pushRule.actions);
|
||||
const [changeState, change] = useAsyncCallback(onChange);
|
||||
const changing = changeState.status === AsyncStatus.Loading;
|
||||
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleSelect = (mode: NotificationMode) => {
|
||||
setMenuCords(undefined);
|
||||
change(mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={
|
||||
changing ? (
|
||||
<Spinner variant="Secondary" size="300" />
|
||||
) : (
|
||||
<Icon size="300" src={Icons.ChevronBottom} />
|
||||
)
|
||||
}
|
||||
onClick={handleMenu}
|
||||
disabled={changing}
|
||||
>
|
||||
<Text size="T300">{modeToStr[selectedMode]}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{modes.map((mode) => (
|
||||
<MenuItem
|
||||
key={mode}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
aria-selected={mode === selectedMode}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(mode)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T300">{modeToStr[mode]}</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
src/app/features/settings/notifications/Notifications.tsx
Normal file
117
src/app/features/settings/notifications/Notifications.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import React from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button, color } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { usePermissionState } from '../../../hooks/usePermission';
|
||||
import { AllMessagesNotifications } from './AllMessages';
|
||||
import { SpecialMessagesNotifications } from './SpecialMessages';
|
||||
import { KeywordMessagesNotifications } from './KeywordMessages';
|
||||
import { IgnoredUserList } from './IgnoredUserList';
|
||||
|
||||
function SystemNotification() {
|
||||
const notifPermission = usePermissionState(
|
||||
'notifications',
|
||||
window.Notification.permission === 'default' ? 'prompt' : window.Notification.permission
|
||||
);
|
||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||
settingsAtom,
|
||||
'isNotificationSounds'
|
||||
);
|
||||
|
||||
const requestNotificationPermission = () => {
|
||||
window.Notification.requestPermission();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">System</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Desktop Notifications"
|
||||
description={
|
||||
notifPermission === 'denied' ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
Notification permission is blocked. Please allow notification permission from
|
||||
browser address bar.
|
||||
</Text>
|
||||
) : (
|
||||
<span>Show desktop notifications when message arrive.</span>
|
||||
)
|
||||
}
|
||||
after={
|
||||
notifPermission === 'prompt' ? (
|
||||
<Button size="300" radii="300" onClick={requestNotificationPermission}>
|
||||
<Text size="B300">Enable</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Switch
|
||||
disabled={notifPermission !== 'granted'}
|
||||
value={showNotifications}
|
||||
onChange={setShowNotifications}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Notification Sound"
|
||||
description="Play sound when new message arrive."
|
||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationsProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function Notifications({ requestClose }: NotificationsProps) {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Notifications
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<SystemNotification />
|
||||
<AllMessagesNotifications />
|
||||
<SpecialMessagesNotifications />
|
||||
<KeywordMessagesNotifications />
|
||||
<IgnoredUserList />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
222
src/app/features/settings/notifications/SpecialMessages.tsx
Normal file
222
src/app/features/settings/notifications/SpecialMessages.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ConditionKind, IPushRules, PushRuleKind, RuleId } from 'matrix-js-sdk';
|
||||
import { Box, Text, Badge } from 'folds';
|
||||
import { useAccountData } from '../../../hooks/useAccountData';
|
||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule';
|
||||
import {
|
||||
getNotificationModeActions,
|
||||
NotificationMode,
|
||||
NotificationModeOptions,
|
||||
useNotificationModeActions,
|
||||
} from '../../../hooks/useNotificationMode';
|
||||
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
|
||||
|
||||
const NOTIFY_MODE_OPS: NotificationModeOptions = {
|
||||
highlight: true,
|
||||
};
|
||||
const getDefaultIsUserMention = (userId: string): PushRuleData =>
|
||||
makePushRuleData(
|
||||
PushRuleKind.Override,
|
||||
RuleId.IsUserMention,
|
||||
getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
|
||||
[
|
||||
{
|
||||
kind: ConditionKind.EventPropertyContains,
|
||||
key: 'content.m\\.mentions.user_ids',
|
||||
value: userId,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const DefaultContainsDisplayName = makePushRuleData(
|
||||
PushRuleKind.Override,
|
||||
RuleId.ContainsDisplayName,
|
||||
getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
|
||||
[
|
||||
{
|
||||
kind: ConditionKind.ContainsDisplayName,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const getDefaultContainsUsername = (username: string) =>
|
||||
makePushRuleData(
|
||||
PushRuleKind.ContentSpecific,
|
||||
RuleId.ContainsUserName,
|
||||
getNotificationModeActions(NotificationMode.NotifyLoud, { highlight: true }),
|
||||
undefined,
|
||||
username
|
||||
);
|
||||
|
||||
const DefaultIsRoomMention = makePushRuleData(
|
||||
PushRuleKind.Override,
|
||||
RuleId.IsRoomMention,
|
||||
getNotificationModeActions(NotificationMode.Notify, { highlight: true }),
|
||||
[
|
||||
{
|
||||
kind: ConditionKind.EventPropertyIs,
|
||||
key: 'content.m\\.mentions.room',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
kind: ConditionKind.SenderNotificationPermission,
|
||||
key: 'room',
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const DefaultAtRoomNotification = makePushRuleData(
|
||||
PushRuleKind.Override,
|
||||
RuleId.AtRoomNotification,
|
||||
getNotificationModeActions(NotificationMode.Notify, { highlight: true }),
|
||||
[
|
||||
{
|
||||
kind: ConditionKind.EventMatch,
|
||||
key: 'content.body',
|
||||
pattern: '@room',
|
||||
},
|
||||
{
|
||||
kind: ConditionKind.SenderNotificationPermission,
|
||||
key: 'room',
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
type PushRulesProps = {
|
||||
ruleId: RuleId;
|
||||
pushRules: IPushRules;
|
||||
defaultPushRuleData: PushRuleData;
|
||||
};
|
||||
function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRulesProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const { kind, pushRule } = usePushRule(pushRules, ruleId) ?? defaultPushRuleData;
|
||||
const getModeActions = useNotificationModeActions(NOTIFY_MODE_OPS);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (mode: NotificationMode) => {
|
||||
const actions = getModeActions(mode);
|
||||
await mx.setPushRuleActions('global', kind, ruleId, actions);
|
||||
},
|
||||
[mx, getModeActions, kind, ruleId]
|
||||
);
|
||||
|
||||
return <NotificationModeSwitcher pushRule={pushRule} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
export function SpecialMessagesNotifications() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const { displayName } = useUserProfile(userId);
|
||||
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
|
||||
const pushRules = useMemo(
|
||||
() => pushRulesEvt?.getContent<IPushRules>() ?? { global: {} },
|
||||
[pushRulesEvt]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Special Messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Success" fill="Solid">
|
||||
<Text size="L400">1</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={`Mention User ID ("${userId}")`}
|
||||
after={
|
||||
<MentionModeSwitcher
|
||||
pushRules={pushRules}
|
||||
ruleId={RuleId.IsUserMention}
|
||||
defaultPushRuleData={getDefaultIsUserMention(userId)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={`Contains Displayname ${displayName ? `("${displayName}")` : ''}`}
|
||||
after={
|
||||
<MentionModeSwitcher
|
||||
pushRules={pushRules}
|
||||
ruleId={RuleId.ContainsDisplayName}
|
||||
defaultPushRuleData={DefaultContainsDisplayName}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={`Contains Username ("${getMxIdLocalPart(userId)}")`}
|
||||
after={
|
||||
<MentionModeSwitcher
|
||||
pushRules={pushRules}
|
||||
ruleId={RuleId.ContainsUserName}
|
||||
defaultPushRuleData={getDefaultContainsUsername(getMxIdLocalPart(userId) ?? userId)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Mention @room"
|
||||
after={
|
||||
<MentionModeSwitcher
|
||||
pushRules={pushRules}
|
||||
ruleId={RuleId.IsRoomMention}
|
||||
defaultPushRuleData={DefaultIsRoomMention}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Contains @room"
|
||||
after={
|
||||
<MentionModeSwitcher
|
||||
pushRules={pushRules}
|
||||
ruleId={RuleId.AtRoomNotification}
|
||||
defaultPushRuleData={DefaultAtRoomNotification}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/features/settings/notifications/index.ts
Normal file
1
src/app/features/settings/notifications/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Notifications';
|
||||
6
src/app/features/settings/styles.css.ts
Normal file
6
src/app/features/settings/styles.css.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const SequenceCardStyle = style({
|
||||
padding: config.space.S300,
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue