feat(settings): redesign settings as a grouped Dawn list and drop the Developer Tools tab

This commit is contained in:
heaven 2026-06-03 00:27:29 +03:00
parent 331366cf40
commit a3dbe0df78
32 changed files with 865 additions and 1410 deletions

View file

@ -88,7 +88,6 @@
"menu_notifications": "Notifications", "menu_notifications": "Notifications",
"menu_devices": "Devices", "menu_devices": "Devices",
"menu_emojis_stickers": "Emojis & Stickers", "menu_emojis_stickers": "Emojis & Stickers",
"menu_developer_tools": "Developer Tools",
"menu_about": "About", "menu_about": "About",
"drag_to_close": "Drag down to close", "drag_to_close": "Drag down to close",
"close": "Close", "close": "Close",
@ -105,7 +104,6 @@
"theme_light": "Light", "theme_light": "Light",
"theme_dark": "Dark", "theme_dark": "Dark",
"theme": "Theme", "theme": "Theme",
"monochrome_mode": "Monochrome Mode",
"twitter_emoji": "Twitter Emoji", "twitter_emoji": "Twitter Emoji",
"page_zoom": "Page Zoom", "page_zoom": "Page Zoom",
"save": "Save", "save": "Save",
@ -116,12 +114,13 @@
"hide_activity": "Hide Typing & Read Receipts", "hide_activity": "Hide Typing & Read Receipts",
"hide_activity_desc": "Turn off both typing status and read receipts to keep your activity private.", "hide_activity_desc": "Turn off both typing status and read receipts to keep your activity private.",
"messages": "Messages", "messages": "Messages",
"hide_membership": "Hide Membership Change", "hide_service_events": "Hide Service Events",
"hide_profile": "Hide Profile Change", "hide_service_events_desc": "Hide joins, leaves, name and avatar changes from the timeline.",
"disable_media_auto_load": "Disable Media Auto Load", "disable_media_auto_load": "Disable Media Auto Load",
"url_preview": "Url Preview", "url_preview": "Url Preview",
"url_preview_encrypted": "Url Preview in Encrypted Room", "advanced": "Advanced",
"show_hidden_events": "Show Hidden Events", "developer_mode": "Developer Mode",
"developer_mode_desc": "Unlock technical tools, like viewing the source of messages.",
"account_title": "Account", "account_title": "Account",
"profile": "Profile", "profile": "Profile",
"avatar": "Avatar", "avatar": "Avatar",
@ -139,7 +138,6 @@
"select_user": "Select User", "select_user": "Select User",
"select_user_desc": "Prevent receiving messages or invites from user by adding their userId.", "select_user_desc": "Prevent receiving messages or invites from user by adding their userId.",
"block": "Block", "block": "Block",
"users": "Users",
"notifications_title": "Notifications", "notifications_title": "Notifications",
"block_messages": "Block Messages", "block_messages": "Block Messages",
"block_messages_moved": "This option has been moved to \"Account > Block Users\" section.", "block_messages_moved": "This option has been moved to \"Account > Block Users\" section.",
@ -166,7 +164,6 @@
"email_send_notif_to": "Send notification to your email. (\"{{email}}\")", "email_send_notif_to": "Send notification to your email. (\"{{email}}\")",
"unexpected_error": "Unexpected Error!", "unexpected_error": "Unexpected Error!",
"all_messages": "All Messages", "all_messages": "All Messages",
"badge": "Badge: ",
"one_to_one": "1-to-1 Chats", "one_to_one": "1-to-1 Chats",
"one_to_one_encrypted": "1-to-1 Chats (Encrypted)", "one_to_one_encrypted": "1-to-1 Chats (Encrypted)",
"rooms": "Rooms", "rooms": "Rooms",
@ -256,26 +253,15 @@
"apply_ready": "Changes saved! Apply when ready.", "apply_ready": "Changes saved! Apply when ready.",
"apply_changes": "Apply Changes", "apply_changes": "Apply Changes",
"about_title": "About", "about_title": "About",
"about_tagline": "Yet another matrix client.", "about_tagline": "A messenger for everyone.",
"options": "Options", "about_connected": "Connected to",
"clear_cache_title": "Clear Cache & Reload", "clear_cache_title": "Clear Cache & Reload",
"clear_cache_desc": "Clear all your locally stored data and reload from server.", "clear_cache_desc": "Clear all your locally stored data and reload from server.",
"clear_cache": "Clear Cache", "clear_cache": "Clear Cache",
"legal": "Legal",
"privacy_policy_title": "Privacy Policy", "privacy_policy_title": "Privacy Policy",
"privacy_policy_desc": "How your data is handled.", "privacy_policy_desc": "How your data is handled.",
"privacy_policy_open": "Open", "privacy_policy_open": "Open",
"credits": "Credits", "about_credits": "Vojo is built on open-source software — including matrix-js-sdk (Apache 2.0), Twemoji (CC-BY 4.0) and Material Design sounds (CC-BY 4.0)."
"devtools_title": "Developer Tools",
"enable_devtools": "Enable Developer Tools",
"access_token": "Access Token",
"access_token_desc": "Copy access token to clipboard.",
"account_data": "Account Data",
"account_data_global": "Global",
"account_data_desc": "Data stored in your global account data.",
"events": "Events",
"total": "Total: {{count}}",
"add_new": "Add New"
}, },
"Search": { "Search": {
"search": "Search", "search": "Search",

View file

@ -88,7 +88,6 @@
"menu_notifications": "Уведомления", "menu_notifications": "Уведомления",
"menu_devices": "Устройства", "menu_devices": "Устройства",
"menu_emojis_stickers": "Эмодзи и стикеры", "menu_emojis_stickers": "Эмодзи и стикеры",
"menu_developer_tools": "Инструменты разработчика",
"menu_about": "О приложении", "menu_about": "О приложении",
"drag_to_close": "Потянуть вниз чтобы закрыть", "drag_to_close": "Потянуть вниз чтобы закрыть",
"close": "Закрыть", "close": "Закрыть",
@ -105,7 +104,6 @@
"theme_light": "Светлая", "theme_light": "Светлая",
"theme_dark": "Тёмная", "theme_dark": "Тёмная",
"theme": "Тема", "theme": "Тема",
"monochrome_mode": "Монохромный режим",
"twitter_emoji": "Эмодзи Twitter", "twitter_emoji": "Эмодзи Twitter",
"page_zoom": "Масштаб страницы", "page_zoom": "Масштаб страницы",
"save": "Сохранить", "save": "Сохранить",
@ -116,12 +114,13 @@
"hide_activity": "Скрыть набор текста и уведомления о прочтении", "hide_activity": "Скрыть набор текста и уведомления о прочтении",
"hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.", "hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.",
"messages": "Сообщения", "messages": "Сообщения",
"hide_membership": "Скрыть изменения участников", "hide_service_events": "Скрывать служебные сообщения",
"hide_profile": "Скрыть изменения профиля", "hide_service_events_desc": "Скрывать вступления, выходы и смену имени или аватара в ленте.",
"disable_media_auto_load": "Отключить автозагрузку медиа", "disable_media_auto_load": "Отключить автозагрузку медиа",
"url_preview": "Предпросмотр ссылок", "url_preview": "Предпросмотр ссылок",
"url_preview_encrypted": "Предпросмотр ссылок в зашифрованных комнатах", "advanced": "Дополнительно",
"show_hidden_events": "Показывать скрытые события", "developer_mode": "Режим разработчика",
"developer_mode_desc": "Открывает технические функции, например исходный код сообщений.",
"account_title": "Аккаунт", "account_title": "Аккаунт",
"profile": "Профиль", "profile": "Профиль",
"avatar": "Аватар", "avatar": "Аватар",
@ -139,7 +138,6 @@
"select_user": "Выбрать пользователя", "select_user": "Выбрать пользователя",
"select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.", "select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.",
"block": "Заблокировать", "block": "Заблокировать",
"users": "Пользователи",
"notifications_title": "Уведомления", "notifications_title": "Уведомления",
"block_messages": "Блокировка сообщений", "block_messages": "Блокировка сообщений",
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».", "block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
@ -166,7 +164,6 @@
"email_send_notif_to": "Отправлять уведомления на вашу почту. (\"{{email}}\")", "email_send_notif_to": "Отправлять уведомления на вашу почту. (\"{{email}}\")",
"unexpected_error": "Непредвиденная ошибка!", "unexpected_error": "Непредвиденная ошибка!",
"all_messages": "Все сообщения", "all_messages": "Все сообщения",
"badge": "Значок: ",
"one_to_one": "Личные чаты", "one_to_one": "Личные чаты",
"one_to_one_encrypted": "Личные чаты (зашифрованные)", "one_to_one_encrypted": "Личные чаты (зашифрованные)",
"rooms": "Комнаты", "rooms": "Комнаты",
@ -256,26 +253,15 @@
"apply_ready": "Изменения сохранены! Примените, когда будете готовы.", "apply_ready": "Изменения сохранены! Примените, когда будете готовы.",
"apply_changes": "Применить изменения", "apply_changes": "Применить изменения",
"about_title": "О приложении", "about_title": "О приложении",
"about_tagline": "Ещё один клиент для Matrix.", "about_tagline": "Вседоступный мессенджер.",
"options": "Параметры", "about_connected": "Подключено к",
"clear_cache_title": "Очистить кэш и перезагрузить", "clear_cache_title": "Очистить кэш и перезагрузить",
"clear_cache_desc": "Удалить все локально сохранённые данные и загрузить заново с сервера.", "clear_cache_desc": "Удалить все локально сохранённые данные и загрузить заново с сервера.",
"clear_cache": "Очистить кэш", "clear_cache": "Очистить кэш",
"legal": "Юридическое",
"privacy_policy_title": "Политика конфиденциальности", "privacy_policy_title": "Политика конфиденциальности",
"privacy_policy_desc": "Как обрабатываются ваши данные.", "privacy_policy_desc": "Как обрабатываются ваши данные.",
"privacy_policy_open": "Открыть", "privacy_policy_open": "Открыть",
"credits": "Благодарности", "about_credits": "Vojo создан на открытом ПО — включая matrix-js-sdk (Apache 2.0), Twemoji (CC-BY 4.0) и звуки Material Design (CC-BY 4.0)."
"devtools_title": "Инструменты разработчика",
"enable_devtools": "Включить инструменты разработчика",
"access_token": "Токен доступа",
"access_token_desc": "Скопировать токен доступа в буфер обмена.",
"account_data": "Данные аккаунта",
"account_data_global": "Глобальные",
"account_data_desc": "Данные, хранящиеся в глобальных данных вашего аккаунта.",
"events": "События",
"total": "Всего: {{count}}",
"add_new": "Добавить"
}, },
"Search": { "Search": {
"search": "Поиск", "search": "Поиск",

View file

@ -23,7 +23,6 @@ import { Account } from './account';
import { Notifications } from './notifications'; import { Notifications } from './notifications';
import { Devices } from './devices'; import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers'; import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools';
import { About } from './about'; import { About } from './about';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@ -35,7 +34,6 @@ export enum SettingsPages {
NotificationPage, NotificationPage,
DevicesPage, DevicesPage,
EmojisStickersPage, EmojisStickersPage,
DeveloperToolsPage,
AboutPage, AboutPage,
} }
@ -56,7 +54,6 @@ export const SETTINGS_PAGE_PARAM = {
notifications: SettingsPages.NotificationPage, notifications: SettingsPages.NotificationPage,
devices: SettingsPages.DevicesPage, devices: SettingsPages.DevicesPage,
emojis: SettingsPages.EmojisStickersPage, emojis: SettingsPages.EmojisStickersPage,
'developer-tools': SettingsPages.DeveloperToolsPage,
about: SettingsPages.AboutPage, about: SettingsPages.AboutPage,
} as const satisfies Record<string, SettingsPages>; } as const satisfies Record<string, SettingsPages>;
export const SETTINGS_PARAM_DEVICES = 'devices'; export const SETTINGS_PARAM_DEVICES = 'devices';
@ -87,11 +84,6 @@ const SETTINGS_MENU_ITEMS: SettingsMenuItem[] = [
nameKey: 'Settings.menu_emojis_stickers', nameKey: 'Settings.menu_emojis_stickers',
icon: Icons.Smile, icon: Icons.Smile,
}, },
{
page: SettingsPages.DeveloperToolsPage,
nameKey: 'Settings.menu_developer_tools',
icon: Icons.Terminal,
},
{ page: SettingsPages.AboutPage, nameKey: 'Settings.menu_about', icon: Icons.Info }, { page: SettingsPages.AboutPage, nameKey: 'Settings.menu_about', icon: Icons.Info },
]; ];
@ -148,9 +140,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
target && target &&
(target.tagName === 'INPUT' || (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) { ) {
return; return;
} }
@ -301,9 +291,6 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.EmojisStickersPage && ( {activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} /> <EmojisStickers requestClose={handlePageRequestClose} />
)} )}
{activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />} {activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot> </PageRoot>
); );

View file

@ -0,0 +1,51 @@
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { Page, PageContent, PageHeader } from '../../components/page';
type SettingsPageProps = {
title: ReactNode;
requestClose: () => void;
children: ReactNode;
};
/**
* Shared chrome for every settings sub-page Dawn `SurfaceVariant` surface, a
* title header with the close button, and a hover-scrolled content column.
* Sections inside are spaced with a single rhythm (gap 500) so grouped panels
* read as a consistent vertical stack.
*/
export function SettingsPage({ title, requestClose, children }: SettingsPageProps) {
const { t } = useTranslation();
return (
<Page variant="SurfaceVariant">
<PageHeader outlined={false}>
<Box grow="Yes" gap="200" alignItems="Center">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
{title}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
onClick={requestClose}
variant="SurfaceVariant"
aria-label={t('Settings.close')}
>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="500">
{children}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -102,9 +102,7 @@ export function SettingsScreen() {
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
target && target &&
(target.tagName === 'INPUT' || (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) { ) {
return; return;
} }

View file

@ -0,0 +1,68 @@
import React, { Children, ReactNode } from 'react';
import { Box, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card';
import { ContainerColorVariants } from '../../styles/ContainerColor.css';
import { SectionFootnote, SectionLabel, SettingRow } from './styles.css';
type SettingsSectionProps = {
// Uppercase muted heading above the panel. Omit for an unlabelled panel.
label?: ReactNode;
// Small caption under the label (rare — e.g. a privacy note).
footnote?: ReactNode;
// Surface tone for the rows. Default `Background` = the inset darker panel
// (#0d0e11) on the SurfaceVariant page (#181a20).
variant?: NonNullable<ContainerColorVariants>['variant'];
// Each direct child becomes one row of the grouped panel. Falsy children
// (conditional rows) are dropped so dividers/rounding stay correct.
children: ReactNode;
};
/**
* A Dawn grouped settings panel: an uppercase muted label over a single
* outlined card whose rows are separated by hairline dividers (via
* `SequenceCard`'s `mergeBorder` + first/last auto-rounding). Replaces the
* stock-Cinny "one floating card per setting" pattern.
*/
export function SettingsSection({
label,
footnote,
variant = 'Background',
children,
}: SettingsSectionProps) {
const rows = Children.toArray(children).filter(Boolean);
if (rows.length === 0) return null;
return (
<Box direction="Column" gap="200">
{label && (
<Box direction="Column">
<Text as="span" className={SectionLabel}>
{label}
</Text>
{footnote && (
<Text as="span" className={SectionFootnote}>
{footnote}
</Text>
)}
</Box>
)}
<Box direction="Column">
{rows.map((row, index) => (
<SequenceCard
// Rows are positional and have no stable id; index key is correct
// here — the list never reorders, only conditionally includes rows.
// eslint-disable-next-line react/no-array-index-key
key={index}
variant={variant}
outlined
mergeBorder
direction="Column"
className={SettingRow}
>
{row}
</SequenceCard>
))}
</Box>
</Box>
);
}

View file

@ -1,13 +1,14 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem } from 'folds'; import { Box, Text, Button, Chip, color, 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 { SettingTile } from '../../../components/setting-tile';
import VojoSVG from '../../../../../public/res/svg/vojo.svg'; import VojoSVG from '../../../../../public/res/svg/vojo.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix'; import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { copyToClipboard } from '../../../utils/dom';
import { SettingsPage } from '../SettingsPage';
import { SettingsSection } from '../SettingsSection';
import { Mono } from '../styles.css';
type AboutProps = { type AboutProps = {
requestClose: () => void; requestClose: () => void;
@ -15,232 +16,122 @@ type AboutProps = {
export function About({ requestClose }: AboutProps) { export function About({ requestClose }: AboutProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const domain = mx.getDomain() ?? 'vojo.chat';
const version = __APP_VERSION__;
return ( return (
<Page variant="SurfaceVariant"> <SettingsPage title={t('Settings.about_title')} requestClose={requestClose}>
<PageHeader outlined={false}> {/* Brand hero centered logo tile, wordmark, tagline, and a copyable
<Box grow="Yes" gap="200"> Fleet-tinted version chip. Deliberately not the stock logo-left row. */}
<Box grow="Yes" alignItems="Center" gap="200"> <Box direction="Column" alignItems="Center" gap="300" style={{ paddingTop: toRem(8) }}>
<Text size="H3" truncate> <Box
{t('Settings.about_title')} alignItems="Center"
</Text> justifyContent="Center"
</Box> shrink="No"
<Box shrink="No"> style={{
<IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}> width: toRem(88),
<Icon src={Icons.Cross} /> height: toRem(88),
</IconButton> borderRadius: toRem(22),
</Box> backgroundColor: color.Background.Container,
border: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
}}
>
<img src={VojoSVG} alt="Vojo" style={{ width: toRem(62), height: toRem(62) }} />
</Box> </Box>
</PageHeader> <Box direction="Column" alignItems="Center" gap="100">
<Box grow="Yes"> <Text size="H2">Vojo</Text>
<Scroll hideTrack visibility="Hover"> <Text size="T300" priority="400" align="Center">
<PageContent style={{ paddingBottom: '1rem' }}> {t('Settings.about_tagline')}
<Box direction="Column" gap="700"> </Text>
<Box gap="400"> </Box>
<Box shrink="No"> <Chip
<img variant="Primary"
style={{ width: toRem(60), height: toRem(60) }} fill="Soft"
src={VojoSVG} radii="Pill"
alt="Vojo logo" onClick={() => copyToClipboard(version)}
/> aria-label={t('Settings.copy')}
</Box> >
<Box direction="Column" gap="300"> <Text as="span" size="T200" className={Mono}>
<Box direction="Column" gap="100"> {version}
<Box gap="100" alignItems="End"> </Text>
<Text size="H3">Vojo</Text> </Chip>
<Text size="T200">{__APP_VERSION__}</Text>
</Box>
<Text>{t('Settings.about_tagline')}</Text>
</Box>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.options')}</Text>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.clear_cache_title')}
description={t('Settings.clear_cache_desc')}
after={
<Button
onClick={() => clearCacheAndReload(mx)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">{t('Settings.clear_cache')}</Text>
</Button>
}
/>
</SequenceCard>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.legal')}</Text>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.privacy_policy_title')}
description={t('Settings.privacy_policy_desc')}
after={
<Button
as="a"
href="https://vojo.chat/privacy"
target="_blank"
rel="noopener noreferrer"
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">{t('Settings.privacy_policy_open')}</Text>
</Button>
}
/>
</SequenceCard>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.credits')}</Text>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
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> </Box>
</Page>
{/* Connection strip the Dawn canon's mono status bar. A green "online"
dot plus the real homeserver this session is connected to. */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S300} ${config.space.S400}`,
backgroundColor: color.Background.Container,
border: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
borderRadius: config.radii.R400,
}}
>
<span
aria-hidden
style={{
width: toRem(7),
height: toRem(7),
borderRadius: '50%',
backgroundColor: color.Success.Main,
flexShrink: 0,
}}
/>
<Text as="span" size="T200" priority="400">
{t('Settings.about_connected')}
</Text>
<Text as="span" size="T200" className={Mono}>
{domain}
</Text>
</Box>
{/* Actions — privacy policy + cache reset, in the shared grouped panel. */}
<SettingsSection>
<SettingTile
title={t('Settings.privacy_policy_title')}
description={t('Settings.privacy_policy_desc')}
after={
<Button
as="a"
href="https://vojo.chat/privacy"
target="_blank"
rel="noopener noreferrer"
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">{t('Settings.privacy_policy_open')}</Text>
</Button>
}
/>
<SettingTile
title={t('Settings.clear_cache_title')}
description={t('Settings.clear_cache_desc')}
after={
<Button
onClick={() => clearCacheAndReload(mx)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">{t('Settings.clear_cache')}</Text>
</Button>
}
/>
</SettingsSection>
{/* Compact open-source attribution Twemoji art & Material sounds are
CC-BY 4.0 and matrix-js-sdk is Apache 2.0, all of which require
attribution, so one quiet line stays in place of the old credits. */}
<Text size="T200" priority="400" align="Center" style={{ opacity: 0.55 }}>
{t('Settings.about_credits')}
</Text>
</SettingsPage>
); );
} }

View file

@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { MatrixId } from './MatrixId'; import { MatrixId } from './MatrixId';
import { Profile } from './Profile'; import { Profile } from './Profile';
import { ContactInformation } from './ContactInfo'; import { ContactInformation } from './ContactInfo';
import { IgnoredUserList } from './IgnoredUserList'; import { IgnoredUserList } from './IgnoredUserList';
import { SettingsPage } from '../SettingsPage';
type AccountProps = { type AccountProps = {
requestClose: () => void; requestClose: () => void;
@ -13,33 +12,11 @@ type AccountProps = {
export function Account({ requestClose }: AccountProps) { export function Account({ requestClose }: AccountProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Page variant="SurfaceVariant"> <SettingsPage title={t('Settings.account_title')} requestClose={requestClose}>
<PageHeader outlined={false}> <Profile />
<Box grow="Yes" gap="200"> <MatrixId />
<Box grow="Yes" alignItems="Center" gap="200"> <ContactInformation />
<Text size="H3" truncate> <IgnoredUserList />
{t('Settings.account_title')} </SettingsPage>
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700">
<Profile />
<MatrixId />
<ContactInformation />
<IgnoredUserList />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
); );
} }

View file

@ -1,11 +1,11 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { Box, Text, Chip } from 'folds'; import { Box, Text, Chip } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingsSection } from '../SettingsSection';
import { Mono } from '../styles.css';
export function ContactInformation() { export function ContactInformation() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -23,28 +23,21 @@ export function ContactInformation() {
}, [loadThreePIds]); }, [loadThreePIds]);
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.contact_info')}>
<Text size="L400">{t('Settings.contact_info')}</Text> <SettingTile
<SequenceCard title={t('Settings.email_address')}
className={SequenceCardStyle} description={t('Settings.email_address_desc')}
variant="Background"
direction="Column"
gap="400"
> >
<SettingTile <Box wrap="Wrap" gap="200">
title={t('Settings.email_address')} {emailIds?.map((email) => (
description={t('Settings.email_address_desc')} <Chip key={email.address} as="span" variant="Secondary" radii="Pill">
> <Text size="T200" className={Mono}>
<Box> {email.address}
{emailIds?.map((email) => ( </Text>
<Chip key={email.address} as="span" variant="Secondary" radii="Pill"> </Chip>
<Text size="T200">{email.address}</Text> ))}
</Chip> </Box>
))} </SettingTile>
</Box> </SettingsSection>
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
</SettingTile>
</SequenceCard>
</Box>
); );
} }

View file

@ -1,14 +1,14 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useState } from 'react'; import React, { ChangeEventHandler, FormEventHandler, useCallback, useState } from 'react';
import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds'; import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { isUserId } from '../../../utils/matrix'; import { isUserId } from '../../../utils/matrix';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
import { SettingsSection } from '../SettingsSection';
import { Mono } from '../styles.css';
function IgnoreUserInput({ userList }: { userList: string[] }) { function IgnoreUserInput({ userList }: { userList: string[] }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -123,7 +123,7 @@ function IgnoredUserChip({ userId, userList }: { userId: string; userList: strin
onClick={handleUnignore} onClick={handleUnignore}
disabled={unIgnoring} disabled={unIgnoring}
> >
<Text size="T200" truncate> <Text size="T200" truncate className={Mono}>
{userId} {userId}
</Text> </Text>
</Chip> </Chip>
@ -135,32 +135,19 @@ export function IgnoredUserList() {
const ignoredUsers = useIgnoredUsers(); const ignoredUsers = useIgnoredUsers();
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.blocked_users')}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200"> <SettingTile title={t('Settings.select_user')} description={t('Settings.select_user_desc')}>
<Text size="L400">{t('Settings.blocked_users')}</Text> <Box direction="Column" gap="300">
</Box> <IgnoreUserInput userList={ignoredUsers} />
<SequenceCard {ignoredUsers.length > 0 && (
className={SequenceCardStyle} <Box wrap="Wrap" gap="200">
variant="Background" {ignoredUsers.map((userId) => (
direction="Column" <IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
gap="400" ))}
> </Box>
<SettingTile title={t('Settings.select_user')} description={t('Settings.select_user_desc')}> )}
<Box direction="Column" gap="300"> </Box>
<IgnoreUserInput userList={ignoredUsers} /> </SettingTile>
{ignoredUsers.length > 0 && ( </SettingsSection>
<Box direction="Inherit" gap="100">
<Text size="L400">{t('Settings.users')}</Text>
<Box wrap="Wrap" gap="200">
{ignoredUsers.map((userId) => (
<IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
))}
</Box>
</Box>
)}
</Box>
</SettingTile>
</SequenceCard>
</Box>
); );
} }

View file

@ -1,29 +1,30 @@
import React from 'react'; import React from 'react';
import { Box, Text, Chip } from 'folds'; import { Text, Chip } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthedUserId } from '../../../hooks/useAuthedUserId'; import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { SettingsSection } from '../SettingsSection';
import { Mono } from '../styles.css';
export function MatrixId() { export function MatrixId() {
const { t } = useTranslation(); const { t } = useTranslation();
const userId = useAuthedUserId(); const userId = useAuthedUserId();
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.matrix_id')}>
<Text size="L400">{t('Settings.matrix_id')}</Text> <SettingTile
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400"> title={
<SettingTile <Text as="span" size="T300" className={Mono}>
title={userId} {userId}
after={ </Text>
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}> }
<Text size="T200">{t('Settings.copy')}</Text> after={
</Chip> <Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
} <Text size="T200">{t('Settings.copy')}</Text>
/> </Chip>
</SequenceCard> }
</Box> />
</SettingsSection>
); );
} }

View file

@ -26,9 +26,8 @@ import {
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAuthedUserId } from '../../../hooks/useAuthedUserId'; import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
@ -92,11 +91,7 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
return ( return (
<SettingTile <SettingTile
title={ title={t('Settings.avatar')}
<Text as="span" size="L400">
{t('Settings.avatar')}
</Text>
}
after={ after={
<Avatar size="500" radii="300"> <Avatar size="500" radii="300">
<UserAvatar <UserAvatar
@ -249,13 +244,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
const hasChanges = displayName !== defaultDisplayName; const hasChanges = displayName !== defaultDisplayName;
return ( return (
<SettingTile <SettingTile title={t('Settings.display_name')}>
title={
<Text as="span" size="L400">
{t('Settings.display_name')}
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100"> <Box direction="Column" grow="Yes" gap="100">
<Box <Box
as="form" as="form"
@ -313,12 +302,9 @@ export function Profile() {
const profile = useUserProfile(userId); const profile = useUserProfile(userId);
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.profile')}>
<Text size="L400">{t('Settings.profile')}</Text> <ProfileAvatar userId={userId} profile={profile} />
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400"> <ProfileDisplayName userId={userId} profile={profile} />
<ProfileAvatar userId={userId} profile={profile} /> </SettingsSection>
<ProfileDisplayName userId={userId} profile={profile} />
</SequenceCard>
</Box>
); );
} }

View file

@ -1,102 +0,0 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
import { CutoutCard } from '../../../components/cutout-card';
type AccountDataProps = {
expand: boolean;
onExpandToggle: (expand: boolean) => void;
onSelect: (type: string | null) => void;
};
export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const [accountDataTypes, setAccountDataKeys] = useState(() =>
Array.from(mx.store.accountData.keys())
);
useAccountDataCallback(
mx,
useCallback(() => {
setAccountDataKeys(Array.from(mx.store.accountData.keys()));
}, [mx])
);
return (
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.account_data')}</Text>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.account_data_global')}
description={t('Settings.account_data_desc')}
after={
<Button
onClick={() => onExpandToggle(!expand)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
}
>
<Text size="B300">{expand ? t('Settings.collapse') : t('Settings.expand')}</Text>
</Button>
}
/>
{expand && (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">{t('Settings.events')}</Text>
<Text size="L400">{t('Settings.total', { count: accountDataTypes.length })}</Text>
</Box>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => onSelect(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{t('Settings.add_new')}
</Text>
</Box>
</MenuItem>
{accountDataTypes.sort().map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => onSelect(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
)}
</SequenceCard>
</Box>
);
}

View file

@ -1,136 +0,0 @@
import React, { useCallback, useState } from 'react';
import type { AccountDataEvents } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } 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 { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
AccountDataEditor,
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData';
type DeveloperToolsProps = {
requestClose: () => void;
};
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// Dev tool accepts arbitrary keys; SDK signature wants `keyof AccountDataEvents`.
await mx.setAccountData(type as keyof AccountDataEvents, content);
},
[mx]
);
if (accountDataType !== undefined) {
return (
<AccountDataEditor
type={accountDataType ?? undefined}
content={
accountDataType
? mx.getAccountData(accountDataType as keyof AccountDataEvents)?.getContent()
: undefined
}
submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)}
/>
);
}
return (
<Page variant="SurfaceVariant">
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
{t('Settings.devtools_title')}
</Text>
</Box>
<Box shrink="No">
<IconButton
onClick={requestClose}
variant="SurfaceVariant"
aria-label={t('Settings.close')}
>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.options')}</Text>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.enable_devtools')}
after={
<Switch
variant="Primary"
value={developerTools}
onChange={setDeveloperTools}
/>
}
/>
</SequenceCard>
{developerTools && (
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.access_token')}
description={t('Settings.access_token_desc')}
after={
<Button
onClick={() =>
copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">{t('Settings.copy')}</Text>
</Button>
}
/>
</SequenceCard>
)}
</Box>
{developerTools && (
<AccountData
expand={expand}
onExpandToggle={setExpend}
onSelect={setAccountDataType}
/>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

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

View file

@ -25,16 +25,17 @@ import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils
import { BreakWord } from '../../../styles/Text.css'; import { BreakWord } from '../../../styles/Text.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { Mono, SettingRow } from '../styles.css';
import { LogoutDialog } from '../../../components/LogoutDialog'; import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
export function DeviceTilePlaceholder() { export function DeviceTilePlaceholder() {
return ( return (
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SettingRow}
style={{ height: toRem(66) }} style={{ height: toRem(66) }}
variant="Background" variant="Background"
outlined
direction="Column" direction="Column"
gap="400" gap="400"
/> />
@ -52,8 +53,7 @@ function DeviceActiveTime({ ts }: { ts: number }) {
<> <>
{today(ts) && t('Settings.today')} {today(ts) && t('Settings.today')}
{yesterday(ts) && t('Settings.yesterday')} {yesterday(ts) && t('Settings.yesterday')}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)}{' '} {!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
{timeHourMinute(ts)}
</> </>
</Text> </Text>
); );
@ -66,13 +66,13 @@ function DeviceDetails({ device }: { device: IMyDevice }) {
{typeof device.device_id === 'string' && ( {typeof device.device_id === 'string' && (
<Text className={BreakWord} size="T200" priority="300"> <Text className={BreakWord} size="T200" priority="300">
{t('Settings.device_id')} {t('Settings.device_id')}
<i>{device.device_id}</i> <span className={Mono}>{device.device_id}</span>
</Text> </Text>
)} )}
{typeof device.last_seen_ip === 'string' && ( {typeof device.last_seen_ip === 'string' && (
<Text className={BreakWord} size="T200" priority="300"> <Text className={BreakWord} size="T200" priority="300">
{t('Settings.ip_address')} {t('Settings.ip_address')}
<i>{device.last_seen_ip}</i> <span className={Mono}>{device.last_seen_ip}</span>
</Text> </Text>
)} )}
</> </>
@ -100,9 +100,9 @@ export function DeviceKeyDetails({ crypto }: DeviceKeyDetailsProps) {
return ( return (
<Text className={BreakWord} size="T200" priority="300"> <Text className={BreakWord} size="T200" priority="300">
{t('Settings.device_key')} {t('Settings.device_key')}
<i> <span className={Mono}>
{keysState.status === AsyncStatus.Success ? keysState.data.ed25519 : t('Settings.loading')} {keysState.status === AsyncStatus.Success ? keysState.data.ed25519 : t('Settings.loading')}
</i> </span>
</Text> </Text>
); );
} }

View file

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds'; import { Box, Text } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SectionLabel, SettingRow } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsPage } from '../SettingsPage';
import { SettingsSection } from '../SettingsSection';
import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '../../../hooks/useDeviceList'; import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '../../../hooks/useDeviceList';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { LocalBackup } from './LocalBackup'; import { LocalBackup } from './LocalBackup';
@ -67,101 +68,74 @@ export function Devices({ requestClose }: DevicesProps) {
); );
return ( return (
<Page variant="SurfaceVariant"> <SettingsPage title={t('Settings.devices_title')} requestClose={requestClose}>
<PageHeader outlined={false}> <SettingsSection label={t('Settings.security')}>
<Box grow="Yes" gap="200"> <SettingTile
<Box grow="Yes" alignItems="Center" gap="200"> title={t('Settings.device_verification')}
<Text size="H3" truncate> description={t('Settings.device_verification_desc')}
{t('Settings.devices_title')} after={
</Text> <>
</Box> <EnableVerification visible={!crossSigningActive} />
<Box shrink="No"> {crossSigningActive && (
<IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}> <Box gap="200" alignItems="Center">
<Icon src={Icons.Cross} /> <VerificationStatusBadge
</IconButton> verificationStatus={verificationStatus}
</Box> otherUnverifiedCount={unverifiedDeviceCount}
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.security')}</Text>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.device_verification')}
description={t('Settings.device_verification_desc')}
after={
<>
<EnableVerification visible={!crossSigningActive} />
{crossSigningActive && (
<Box gap="200" alignItems="Center">
<VerificationStatusBadge
verificationStatus={verificationStatus}
otherUnverifiedCount={unverifiedDeviceCount}
/>
<DeviceVerificationOptions />
</Box>
)}
</>
}
/> />
</SequenceCard> <DeviceVerificationOptions />
</Box> </Box>
<Box direction="Column" gap="100"> )}
<Text size="L400">{t('Settings.current')}</Text> </>
{currentDevice ? ( }
<SequenceCard />
className={SequenceCardStyle} </SettingsSection>
variant="Background" <Box direction="Column" gap="200">
direction="Column" <Text as="span" className={SectionLabel}>
gap="400" {t('Settings.current')}
> </Text>
<DeviceTile {currentDevice ? (
device={currentDevice} <SequenceCard
refreshDeviceList={refreshDeviceList} className={SettingRow}
options={<DeviceLogoutBtn />} variant="Background"
> outlined
{crypto && <DeviceKeyDetails crypto={crypto} />} direction="Column"
</DeviceTile> gap="400"
{crossSigningActive && >
verificationStatus === VerificationStatus.Unverified && <DeviceTile
defaultSecretStorageKeyId && device={currentDevice}
defaultSecretStorageKeyContent && ( refreshDeviceList={refreshDeviceList}
<VerifyCurrentDeviceTile options={<DeviceLogoutBtn />}
secretStorageKeyId={defaultSecretStorageKeyId} >
secretStorageKeyContent={defaultSecretStorageKeyContent} {crypto && <DeviceKeyDetails crypto={crypto} />}
/> </DeviceTile>
)} {crossSigningActive &&
{crypto && verificationStatus === VerificationStatus.Verified && ( verificationStatus === VerificationStatus.Unverified &&
<BackupRestoreTile crypto={crypto} /> defaultSecretStorageKeyId &&
)} defaultSecretStorageKeyContent && (
</SequenceCard> <VerifyCurrentDeviceTile
) : ( secretStorageKeyId={defaultSecretStorageKeyId}
<DeviceTilePlaceholder /> secretStorageKeyContent={defaultSecretStorageKeyContent}
)}
</Box>
{devices === undefined && <DevicesPlaceholder />}
{otherDevices && (
<OtherDevices
devices={otherDevices}
refreshDeviceList={refreshDeviceList}
showVerification={
crossSigningActive && verificationStatus === VerificationStatus.Verified
}
/> />
)} )}
<LocalBackup /> {crypto && verificationStatus === VerificationStatus.Verified && (
</Box> <BackupRestoreTile crypto={crypto} />
</PageContent> )}
</Scroll> </SequenceCard>
) : (
<DeviceTilePlaceholder />
)}
</Box> </Box>
</Page> {devices === undefined && <DevicesPlaceholder />}
{otherDevices && (
<OtherDevices
devices={otherDevices}
refreshDeviceList={refreshDeviceList}
showVerification={
crossSigningActive && verificationStatus === VerificationStatus.Verified
}
/>
)}
<LocalBackup />
</SettingsPage>
); );
} }

View file

@ -2,9 +2,8 @@ import React, { FormEventHandler, useCallback, useEffect, useState } from 'react
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds'; import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css'; import { SettingsSection } from '../SettingsSection';
import { PasswordInput } from '../../../components/password-input'; import { PasswordInput } from '../../../components/password-input';
import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch'; import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -308,24 +307,9 @@ function ImportKeysTile() {
export function LocalBackup() { export function LocalBackup() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.local_backup')}>
<Text size="L400">{t('Settings.local_backup')}</Text> <ExportKeysTile />
<SequenceCard <ImportKeysTile />
className={SequenceCardStyle} </SettingsSection>
variant="Background"
direction="Column"
gap="400"
>
<ExportKeysTile />
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<ImportKeysTile />
</SequenceCard>
</Box>
); );
} }

View file

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Box, Button, config, Menu, Spinner, Text } from 'folds'; import { Box, Button, config, Menu, Spinner, Text } from 'folds';
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk'; import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SectionLabel, SettingRow } from '../styles.css';
import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA'; import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA';
import { DeviceDeleteBtn, DeviceTile } from './DeviceTile'; import { DeviceDeleteBtn, DeviceTile } from './DeviceTile';
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback'; import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
@ -104,83 +104,91 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
return devices.length > 0 ? ( return devices.length > 0 ? (
<> <>
<Box direction="Column" gap="100"> <Box direction="Column" gap="200">
<Text size="L400">{t('Settings.others')}</Text> <Text as="span" className={SectionLabel}>
{authMetadata && ( {t('Settings.others')}
<SequenceCard </Text>
className={SequenceCardStyle} <Box direction="Column">
variant="Background" {authMetadata && (
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.device_dashboard')}
description={t('Settings.device_dashboard_desc')}
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={handleDashboardOIDC}
>
<Text size="B300">{t('Settings.open')}</Text>
</Button>
}
/>
</SequenceCard>
)}
{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 <SequenceCard
key={device.device_id} className={SettingRow}
className={SequenceCardStyle} variant="Background"
variant={deleted.has(device.device_id) ? 'Critical' : 'SurfaceVariant'} outlined
mergeBorder
direction="Column" direction="Column"
gap="400" gap="400"
> >
<DeviceTile <SettingTile
device={device} title={t('Settings.device_dashboard')}
deleted={deleted.has(device.device_id)} description={t('Settings.device_dashboard_desc')}
refreshDeviceList={refreshDeviceList} after={
disabled={deleting} <Button
options={ size="300"
authMetadata ? ( variant="Secondary"
<DeviceDeleteBtn fill="Soft"
deviceId={device.device_id} radii="300"
deleted={false} outlined
onDeleteToggle={handleDeleteOIDC} onClick={handleDashboardOIDC}
/> >
) : ( <Text size="B300">{t('Settings.open')}</Text>
<DeviceDeleteBtn </Button>
deviceId={device.device_id}
deleted={deleted.has(device.device_id)}
onDeleteToggle={handleToggleDelete}
disabled={deleting}
/>
)
} }
/> />
{showVerification && crypto && ( </SequenceCard>
<DeviceVerificationStatus )}
crypto={crypto} {devices
userId={mx.getSafeUserId()} .sort((d1, d2) => {
deviceId={device.device_id} if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
> return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
{(status) => })
status === VerificationStatus.Unverified && ( .map((device) => (
<VerifyOtherDeviceTile crypto={crypto} deviceId={device.device_id} /> <SequenceCard
key={device.device_id}
className={SettingRow}
variant={deleted.has(device.device_id) ? 'Critical' : 'Background'}
outlined
mergeBorder={!deleted.has(device.device_id)}
direction="Column"
gap="400"
>
<DeviceTile
device={device}
deleted={deleted.has(device.device_id)}
refreshDeviceList={refreshDeviceList}
disabled={deleting}
options={
authMetadata ? (
<DeviceDeleteBtn
deviceId={device.device_id}
deleted={false}
onDeleteToggle={handleDeleteOIDC}
/>
) : (
<DeviceDeleteBtn
deviceId={device.device_id}
deleted={deleted.has(device.device_id)}
onDeleteToggle={handleToggleDelete}
disabled={deleting}
/>
) )
} }
</DeviceVerificationStatus> />
)} {showVerification && crypto && (
</SequenceCard> <DeviceVerificationStatus
))} crypto={crypto}
userId={mx.getSafeUserId()}
deviceId={device.device_id}
>
{(status) =>
status === VerificationStatus.Unverified && (
<VerifyOtherDeviceTile crypto={crypto} deviceId={device.device_id} />
)
}
</DeviceVerificationStatus>
)}
</SequenceCard>
))}
</Box>
</Box> </Box>
{deleted.size > 0 && ( {deleted.size > 0 && (
<Menu <Menu

View file

@ -1,11 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { GlobalPacks } from './GlobalPacks'; import { GlobalPacks } from './GlobalPacks';
import { UserPack } from './UserPack'; import { UserPack } from './UserPack';
import { ImagePack } from '../../../plugins/custom-emoji'; import { ImagePack } from '../../../plugins/custom-emoji';
import { ImagePackView } from '../../../components/image-pack-view'; import { ImagePackView } from '../../../components/image-pack-view';
import { SettingsPage } from '../SettingsPage';
type EmojisStickersProps = { type EmojisStickersProps = {
requestClose: () => void; requestClose: () => void;
@ -23,31 +22,9 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
} }
return ( return (
<Page variant="SurfaceVariant"> <SettingsPage title={t('Settings.emojis_stickers_title')} requestClose={requestClose}>
<PageHeader outlined={false}> <UserPack onViewPack={setImagePack} />
<Box grow="Yes" gap="200"> <GlobalPacks onViewPack={setImagePack} />
<Box grow="Yes" alignItems="Center" gap="200"> </SettingsPage>
<Text size="H3" truncate>
{t('Settings.emojis_stickers_title')}
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700">
<UserPack onViewPack={setImagePack} />
<GlobalPacks onViewPack={setImagePack} />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
); );
} }

View file

@ -26,7 +26,7 @@ import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useGlobalImagePacks, useRoomsImagePacks } from '../../../hooks/useImagePacks'; import { useGlobalImagePacks, useRoomsImagePacks } from '../../../hooks/useImagePacks';
import { SequenceCardStyle } from '../styles.css'; import { SectionLabel, SettingRow } from '../styles.css';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
@ -182,7 +182,7 @@ function GlobalPackSelector({
return ( return (
<SequenceCard <SequenceCard
key={pack.id} key={pack.id}
className={SequenceCardStyle} className={SettingRow}
variant={added ? 'Success' : 'SurfaceVariant'} variant={added ? 'Success' : 'SurfaceVariant'}
direction="Column" direction="Column"
gap="400" gap="400"
@ -220,7 +220,7 @@ function GlobalPackSelector({
{roomToPacks.size === 0 && ( {roomToPacks.size === 0 && (
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SettingRow}
variant="Background" variant="Background"
direction="Column" direction="Column"
gap="400" gap="400"
@ -357,8 +357,10 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
return ( return (
<SequenceCard <SequenceCard
key={pack.id} key={pack.id}
className={SequenceCardStyle} className={SettingRow}
variant={removed ? 'Critical' : 'SurfaceVariant'} variant={removed ? 'Critical' : 'Background'}
outlined
mergeBorder={!removed}
direction="Column" direction="Column"
gap="400" gap="400"
> >
@ -424,71 +426,77 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
return ( return (
<> <>
<Box direction="Column" gap="100"> <Box direction="Column" gap="200">
<Text size="L400">{t('Settings.favorite_packs')}</Text> <Text as="span" className={SectionLabel}>
<SequenceCard {t('Settings.favorite_packs')}
className={SequenceCardStyle} </Text>
variant="Background" <Box direction="Column">
direction="Column" <SequenceCard
gap="400" className={SettingRow}
> variant="Background"
<SettingTile outlined
title={t('Settings.select_pack')} mergeBorder
description={t('Settings.select_pack_desc')} direction="Column"
after={ gap="400"
<> >
<Button <SettingTile
onClick={handleSelectMenu} title={t('Settings.select_pack')}
variant="Secondary" description={t('Settings.select_pack_desc')}
fill="Soft" after={
size="300" <>
radii="300" <Button
outlined onClick={handleSelectMenu}
> variant="Secondary"
<Text size="B300">{t('Settings.select')}</Text> fill="Soft"
</Button> size="300"
<PopOut radii="300"
anchor={menuCords} outlined
position="Bottom" >
align="End" <Text size="B300">{t('Settings.select')}</Text>
content={ </Button>
<FocusTrap <PopOut
focusTrapOptions={{ anchor={menuCords}
initialFocus: false, position="Bottom"
onDeactivate: () => setMenuCords(undefined), align="End"
clickOutsideDeactivates: true, content={
isKeyForward: (evt: KeyboardEvent) => <FocusTrap
evt.key === 'ArrowDown' || evt.key === 'ArrowRight', focusTrapOptions={{
isKeyBackward: (evt: KeyboardEvent) => initialFocus: false,
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', onDeactivate: () => setMenuCords(undefined),
escapeDeactivates: stopPropagation, clickOutsideDeactivates: true,
}} isKeyForward: (evt: KeyboardEvent) =>
> evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
<Menu isKeyBackward: (evt: KeyboardEvent) =>
style={{ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
display: 'flex', escapeDeactivates: stopPropagation,
maxWidth: toRem(400),
width: '100vw',
maxHeight: toRem(500),
}} }}
> >
<GlobalPackSelector <Menu
packs={unselectedGlobalPacks} style={{
useAuthentication={useAuthentication} display: 'flex',
onSelect={handleSelected} maxWidth: toRem(400),
/> width: '100vw',
</Menu> maxHeight: toRem(500),
</FocusTrap> }}
} >
/> <GlobalPackSelector
</> packs={unselectedGlobalPacks}
} useAuthentication={useAuthentication}
/> onSelect={handleSelected}
</SequenceCard> />
{globalPacks.map(renderPack)} </Menu>
{nonGlobalPacks </FocusTrap>
.filter((pack) => !!selectedPacks.find((addr) => packAddressEqual(pack.address, addr))) }
.map(renderPack)} />
</>
}
/>
</SequenceCard>
{globalPacks.map(renderPack)}
{nonGlobalPacks
.filter((pack) => !!selectedPacks.find((addr) => packAddressEqual(pack.address, addr)))
.map(renderPack)}
</Box>
</Box> </Box>
{hasChanges && ( {hasChanges && (
<Menu <Menu

View file

@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Icon, Icons, Text } from 'folds'; import { Avatar, AvatarFallback, AvatarImage, Button, Icon, Icons, Text } from 'folds';
import { useUserImagePack } from '../../../hooks/useImagePacks'; import { useUserImagePack } from '../../../hooks/useImagePacks';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection';
import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji'; import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
@ -32,37 +31,34 @@ export function UserPack({ onViewPack }: UserPackProps) {
}; };
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.default_pack')}>
<Text size="L400">{t('Settings.default_pack')}</Text> <SettingTile
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400"> title={userPack?.meta.name ?? t('Settings.unknown')}
<SettingTile description={userPack?.meta.attribution}
title={userPack?.meta.name ?? t('Settings.unknown')} before={
description={userPack?.meta.attribution} <Avatar size="300" radii="300">
before={ {avatarUrl ? (
<Avatar size="300" radii="300"> <AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
{avatarUrl ? ( ) : (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} /> <AvatarFallback>
) : ( <Icon size="400" src={Icons.Sticker} filled />
<AvatarFallback> </AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled /> )}
</AvatarFallback> </Avatar>
)} }
</Avatar> after={
} <Button
after={ variant="Secondary"
<Button fill="Soft"
variant="Secondary" size="300"
fill="Soft" radii="300"
size="300" outlined
radii="300" onClick={handleView}
outlined >
onClick={handleView} <Text size="B300">{t('Settings.view')}</Text>
> </Button>
<Text size="B300">{t('Settings.view')}</Text> }
</Button> />
} </SettingsSection>
/>
</SequenceCard>
</Box>
); );
} }

View file

@ -10,14 +10,12 @@ import {
Button, Button,
config, config,
Icon, Icon,
IconButton,
Icons, Icons,
Input, Input,
Menu, Menu,
MenuItem, MenuItem,
PopOut, PopOut,
RectCords, RectCords,
Scroll,
Switch, Switch,
Text, Text,
toRem, toRem,
@ -25,8 +23,6 @@ import {
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
@ -34,7 +30,8 @@ import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent'; import { isMacOS } from '../../../utils/user-agent';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { DarkTheme, LightTheme } from '../../../hooks/useTheme'; import { DarkTheme, LightTheme } from '../../../hooks/useTheme';
import { SequenceCardStyle } from '../styles.css'; import { SettingsPage } from '../SettingsPage';
import { SettingsSection } from '../SettingsSection';
type ThemeChoice = 'system' | typeof LightTheme.id | typeof DarkTheme.id; type ThemeChoice = 'system' | typeof LightTheme.id | typeof DarkTheme.id;
@ -52,10 +49,7 @@ function ThemeSelect() {
choice = LightTheme.id; choice = LightTheme.id;
} }
const choices = useMemo<ThemeChoice[]>( const choices = useMemo<ThemeChoice[]>(() => ['system', LightTheme.id, DarkTheme.id], []);
() => ['system', LightTheme.id, DarkTheme.id],
[]
);
const choiceLabel = useMemo<Record<ThemeChoice, string>>( const choiceLabel = useMemo<Record<ThemeChoice, string>>(
() => ({ () => ({
@ -184,34 +178,17 @@ function PageZoomInput() {
function Appearance() { function Appearance() {
const { t } = useTranslation(); const { t } = useTranslation();
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.appearance')}>
<Text size="L400">{t('Settings.appearance')}</Text> <SettingTile title={t('Settings.theme')} after={<ThemeSelect />} />
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column"> <SettingTile
<SettingTile title={t('Settings.theme')} after={<ThemeSelect />} /> title={t('Settings.twitter_emoji')}
</SequenceCard> after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
/>
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column"> <SettingTile title={t('Settings.page_zoom')} after={<PageZoomInput />} />
<SettingTile </SettingsSection>
title={t('Settings.monochrome_mode')}
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile
title={t('Settings.twitter_emoji')}
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile title={t('Settings.page_zoom')} after={<PageZoomInput />} />
</SequenceCard>
</Box>
); );
} }
@ -222,31 +199,24 @@ function Editor() {
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.editor')}>
<Text size="L400">{t('Settings.editor')}</Text> <SettingTile
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column"> title={t('Settings.enter_newline')}
<SettingTile description={t('Settings.enter_newline_desc', {
title={t('Settings.enter_newline')} key: isMacOS() ? KeySymbol.Command : 'Ctrl',
description={t('Settings.enter_newline_desc', { })}
key: isMacOS() ? KeySymbol.Command : 'Ctrl', after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />}
})} />
after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />} <SettingTile
/> title={t('Settings.markdown')}
</SequenceCard> after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column"> />
<SettingTile <SettingTile
title={t('Settings.markdown')} title={t('Settings.hide_activity')}
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />} description={t('Settings.hide_activity_desc')}
/> after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
</SequenceCard> />
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column"> </SettingsSection>
<SettingTile
title={t('Settings.hide_activity')}
description={t('Settings.hide_activity_desc')}
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
/>
</SequenceCard>
</Box>
); );
} }
@ -262,69 +232,56 @@ function Messages() {
); );
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); // Merged control for the two service-event toggles (joins/leaves +
// name/avatar changes). Reads on only when *both* classes are hidden, and
// writes both atoms together. AND (not OR) keeps the switch honest against
// the default state: out of the box hideMembershipEvents is false (joins/
// leaves shown) and hideNickAvatarEvents is true, so the switch reads OFF
// and the visible join/leave events agree with it. The timeline still reads
// each atom directly, so existing persisted preferences are untouched until
// the user flips this switch (which then sets both to the same value).
const hideServiceEvents = hideMembershipEvents && hideNickAvatarEvents;
const setHideServiceEvents = (value: boolean) => {
setHideMembershipEvents(value);
setHideNickAvatarEvents(value);
};
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.messages')}>
<Text size="L400">{t('Settings.messages')}</Text> <SettingTile
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column"> title={t('Settings.hide_service_events')}
<SettingTile description={t('Settings.hide_service_events_desc')}
title={t('Settings.hide_membership')} after={
after={ <Switch variant="Primary" value={hideServiceEvents} onChange={setHideServiceEvents} />
<Switch }
variant="Primary" />
value={hideMembershipEvents} <SettingTile
onChange={setHideMembershipEvents} title={t('Settings.disable_media_auto_load')}
/> after={
} <Switch variant="Primary" value={!mediaAutoLoad} onChange={(v) => setMediaAutoLoad(!v)} />
/> }
</SequenceCard> />
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column"> <SettingTile
<SettingTile title={t('Settings.url_preview')}
title={t('Settings.hide_profile')} after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
after={ />
<Switch </SettingsSection>
variant="Primary" );
value={hideNickAvatarEvents} }
onChange={setHideNickAvatarEvents}
/> function Advanced() {
} const { t } = useTranslation();
/> const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column"> return (
<SettingTile <SettingsSection label={t('Settings.advanced')}>
title={t('Settings.disable_media_auto_load')} <SettingTile
after={ title={t('Settings.developer_mode')}
<Switch description={t('Settings.developer_mode_desc')}
variant="Primary" after={<Switch variant="Primary" value={developerTools} onChange={setDeveloperTools} />}
value={!mediaAutoLoad} />
onChange={(v) => setMediaAutoLoad(!v)} </SettingsSection>
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile
title={t('Settings.url_preview')}
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile
title={t('Settings.url_preview_encrypted')}
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column">
<SettingTile
title={t('Settings.show_hidden_events')}
after={
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
}
/>
</SequenceCard>
</Box>
); );
} }
@ -334,32 +291,11 @@ type GeneralProps = {
export function General({ requestClose }: GeneralProps) { export function General({ requestClose }: GeneralProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Page variant="SurfaceVariant"> <SettingsPage title={t('Settings.general_title')} requestClose={requestClose}>
<PageHeader outlined={false}> <Appearance />
<Box grow="Yes" gap="200"> <Editor />
<Box grow="Yes" alignItems="Center" gap="200"> <Messages />
<Text size="H3" truncate> <Advanced />
{t('Settings.general_title')} </SettingsPage>
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700">
<Appearance />
<Editor />
<Messages />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
); );
} }

View file

@ -1,3 +1,5 @@
export * from './Settings'; export * from './Settings';
export * from './SettingsScreen'; export * from './SettingsScreen';
export * from './MobileSettingsHorseshoe'; export * from './MobileSettingsHorseshoe';
export * from './SettingsPage';
export * from './SettingsSection';

View file

@ -1,13 +1,11 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge, Box, Text } from 'folds';
import { ConditionKind, IPushRules, PushRuleCondition, PushRuleKind, RuleId } from 'matrix-js-sdk'; import { ConditionKind, IPushRules, PushRuleCondition, PushRuleKind, RuleId } from 'matrix-js-sdk';
import { useAccountData } from '../../../hooks/useAccountData'; import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { NotificationModeSwitcher } from './NotificationModeSwitcher';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection';
import { PushRuleData, usePushRule } from '../../../hooks/usePushRule'; import { PushRuleData, usePushRule } from '../../../hooks/usePushRule';
import { import {
getNotificationModeActions, getNotificationModeActions,
@ -82,73 +80,36 @@ export function AllMessagesNotifications() {
); );
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.all_messages')}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200"> <SettingTile
<Text size="L400">{t('Settings.all_messages')}</Text> title={t('Settings.one_to_one')}
<Box gap="100"> after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
<Text size="T200">{t('Settings.badge')}</Text> />
<Badge radii="300" variant="Secondary" fill="Solid"> <SettingTile
<Text size="L400">1</Text> title={t('Settings.one_to_one_encrypted')}
</Badge> after={
</Box> <AllMessagesModeSwitcher
</Box> pushRules={pushRules}
<SequenceCard ruleId={RuleId.EncryptedDM}
className={SequenceCardStyle} encrypted
variant="Background" oneToOne
direction="Column" />
gap="400" }
> />
<SettingTile <SettingTile
title={t('Settings.one_to_one')} title={t('Settings.rooms')}
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />} after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.Message} />}
/> />
</SequenceCard> <SettingTile
<SequenceCard title={t('Settings.rooms_encrypted')}
className={SequenceCardStyle} after={
variant="Background" <AllMessagesModeSwitcher
direction="Column" pushRules={pushRules}
gap="400" ruleId={RuleId.EncryptedMessage}
> encrypted
<SettingTile />
title={t('Settings.one_to_one_encrypted')} }
after={ />
<AllMessagesModeSwitcher </SettingsSection>
pushRules={pushRules}
ruleId={RuleId.EncryptedDM}
encrypted
oneToOne
/>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.rooms')}
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.Message} />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.rooms_encrypted')}
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
ruleId={RuleId.EncryptedMessage}
encrypted
/>
}
/>
</SequenceCard>
</Box>
); );
} }

View file

@ -1,12 +1,12 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react'; import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk'; import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
import { Box, Text, Badge, Button, Input, config, IconButton, Icons, Icon, Spinner } from 'folds'; import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner } from 'folds';
import { useAccountData } from '../../../hooks/useAccountData'; import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection';
import { Mono } from '../styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { import {
getNotificationModeActions, getNotificationModeActions,
@ -163,44 +163,25 @@ export function KeywordMessagesNotifications() {
}, [pushRules]); }, [pushRules]);
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.keyword_messages')}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200"> <SettingTile
<Text size="L400">{t('Settings.keyword_messages')}</Text> title={t('Settings.select_keyword')}
<Box gap="100"> description={t('Settings.select_keyword_desc')}
<Text size="T200">{t('Settings.badge')}</Text>
<Badge radii="300" variant="Success" fill="Solid">
<Text size="L400">1</Text>
</Badge>
</Box>
</Box>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
> >
<SettingTile <KeywordInput />
title={t('Settings.select_keyword')} </SettingTile>
description={t('Settings.select_keyword_desc')}
>
<KeywordInput />
</SettingTile>
</SequenceCard>
{keywordPushRules.map((pushRule) => ( {keywordPushRules.map((pushRule) => (
<SequenceCard <SettingTile
key={pushRule.rule_id} key={pushRule.rule_id}
className={SequenceCardStyle} title={
variant="Background" <Text as="span" size="T300" className={Mono}>
direction="Column" {`"${pushRule.pattern}"`}
gap="400" </Text>
> }
<SettingTile before={<KeywordCross pushRule={pushRule} />}
title={`"${pushRule.pattern}"`} after={<KeywordModeSwitcher pushRule={pushRule} />}
before={<KeywordCross pushRule={pushRule} />} />
after={<KeywordModeSwitcher pushRule={pushRule} />}
/>
</SequenceCard>
))} ))}
</Box> </SettingsSection>
); );
} }

View file

@ -1,14 +1,12 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SystemNotification } from './SystemNotification'; import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages'; import { AllMessagesNotifications } from './AllMessages';
import { SpecialMessagesNotifications } from './SpecialMessages'; import { SpecialMessagesNotifications } from './SpecialMessages';
import { KeywordMessagesNotifications } from './KeywordMessages'; import { KeywordMessagesNotifications } from './KeywordMessages';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsPage } from '../SettingsPage';
import { SettingsSection } from '../SettingsSection';
type NotificationsProps = { type NotificationsProps = {
requestClose: () => void; requestClose: () => void;
@ -16,44 +14,14 @@ type NotificationsProps = {
export function Notifications({ requestClose }: NotificationsProps) { export function Notifications({ requestClose }: NotificationsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Page variant="SurfaceVariant"> <SettingsPage title={t('Settings.notifications_title')} requestClose={requestClose}>
<PageHeader outlined={false}> <SystemNotification />
<Box grow="Yes" gap="200"> <AllMessagesNotifications />
<Box grow="Yes" alignItems="Center" gap="200"> <SpecialMessagesNotifications />
<Text size="H3" truncate> <KeywordMessagesNotifications />
{t('Settings.notifications_title')} <SettingsSection label={t('Settings.block_messages')}>
</Text> <SettingTile description={t('Settings.block_messages_moved')} />
</Box> </SettingsSection>
<Box shrink="No"> </SettingsPage>
<IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent style={{ paddingBottom: '1rem' }}>
<Box direction="Column" gap="700">
<SystemNotification />
<AllMessagesNotifications />
<SpecialMessagesNotifications />
<KeywordMessagesNotifications />
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.block_messages')}</Text>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile description={t('Settings.block_messages_moved')} />
</SequenceCard>
</Box>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
); );
} }

View file

@ -1,12 +1,10 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ConditionKind, IPushRules, PushRuleKind, RuleId } from 'matrix-js-sdk'; import { ConditionKind, IPushRules, PushRuleKind, RuleId } from 'matrix-js-sdk';
import { Box, Text, Badge } from 'folds';
import { useAccountData } from '../../../hooks/useAccountData'; import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAuthedUserId } from '../../../hooks/useAuthedUserId'; import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
import { useUserProfile } from '../../../hooks/useUserProfile'; import { useUserProfile } from '../../../hooks/useUserProfile';
@ -124,80 +122,61 @@ export function SpecialMessagesNotifications() {
); );
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.special_messages')}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200"> <SettingTile
<Text size="L400">{t('Settings.special_messages')}</Text> title={t('Settings.mention_user_id', { userId })}
<Box gap="100"> after={
<Text size="T200">{t('Settings.badge')}</Text> <MentionModeSwitcher
<Badge radii="300" variant="Success" fill="Solid"> pushRules={pushRules}
<Text size="L400">1</Text> ruleId={RuleId.IsUserMention}
</Badge> defaultPushRuleData={getDefaultIsUserMention(userId)}
</Box> />
</Box> }
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400"> />
<SettingTile <SettingTile
title={t('Settings.mention_user_id', { userId })} title={
after={ displayName
<MentionModeSwitcher ? t('Settings.contains_displayname_value', { displayName })
pushRules={pushRules} : t('Settings.contains_displayname')
ruleId={RuleId.IsUserMention} }
defaultPushRuleData={getDefaultIsUserMention(userId)} after={
/> <MentionModeSwitcher
} pushRules={pushRules}
/> ruleId={RuleId.ContainsDisplayName}
</SequenceCard> defaultPushRuleData={DefaultContainsDisplayName}
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400"> />
<SettingTile }
title={ />
displayName <SettingTile
? t('Settings.contains_displayname_value', { displayName }) title={t('Settings.contains_username', { username: getMxIdLocalPart(userId) })}
: t('Settings.contains_displayname') after={
} <MentionModeSwitcher
after={ pushRules={pushRules}
<MentionModeSwitcher ruleId={RuleId.ContainsUserName}
pushRules={pushRules} defaultPushRuleData={getDefaultContainsUsername(getMxIdLocalPart(userId) ?? userId)}
ruleId={RuleId.ContainsDisplayName} />
defaultPushRuleData={DefaultContainsDisplayName} }
/> />
} <SettingTile
/> title={t('Settings.mention_room')}
</SequenceCard> after={
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400"> <MentionModeSwitcher
<SettingTile pushRules={pushRules}
title={t('Settings.contains_username', { username: getMxIdLocalPart(userId) })} ruleId={RuleId.IsRoomMention}
after={ defaultPushRuleData={DefaultIsRoomMention}
<MentionModeSwitcher />
pushRules={pushRules} }
ruleId={RuleId.ContainsUserName} />
defaultPushRuleData={getDefaultContainsUsername(getMxIdLocalPart(userId) ?? userId)} <SettingTile
/> title={t('Settings.contains_room')}
} after={
/> <MentionModeSwitcher
</SequenceCard> pushRules={pushRules}
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400"> ruleId={RuleId.AtRoomNotification}
<SettingTile defaultPushRuleData={DefaultAtRoomNotification}
title={t('Settings.mention_room')} />
after={ }
<MentionModeSwitcher />
pushRules={pushRules} </SettingsSection>
ruleId={RuleId.IsRoomMention}
defaultPushRuleData={DefaultIsRoomMention}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
<SettingTile
title={t('Settings.contains_room')}
after={
<MentionModeSwitcher
pushRules={pushRules}
ruleId={RuleId.AtRoomNotification}
defaultPushRuleData={DefaultAtRoomNotification}
/>
}
/>
</SequenceCard>
</Box>
); );
} }

View file

@ -1,10 +1,9 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Text, Switch, Button, color, Spinner } from 'folds'; import { Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk'; import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
@ -176,50 +175,26 @@ export function SystemNotification() {
'isNotificationSounds' 'isNotificationSounds'
); );
const [inviteSpamFilter, setInviteSpamFilter] = useSetting(settingsAtom, 'inviteSpamFilter'); const [inviteSpamFilter, setInviteSpamFilter] = useSetting(settingsAtom, 'inviteSpamFilter');
// Gate the push row at the call site: PushNotification renders null when push
// is unavailable, and SettingsSection's `filter(Boolean)` can only drop
// JSX-level falsy children — a returned-null component would otherwise leave
// an empty bordered first row in the grouped panel.
const pushStatus = usePushNotificationStatus();
return ( return (
<Box direction="Column" gap="100"> <SettingsSection label={t('Settings.system')}>
<Text size="L400">{t('Settings.system')}</Text> {pushStatus !== 'unavailable' && <PushNotification />}
<SequenceCard <SettingTile
className={SequenceCardStyle} title={t('Settings.notification_sound')}
variant="Background" description={t('Settings.notification_sound_desc')}
direction="Column" after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
gap="400" />
> <SettingTile
<PushNotification /> title={t('Settings.invite_spam_filter')}
</SequenceCard> description={t('Settings.invite_spam_filter_desc')}
<SequenceCard after={<Switch value={inviteSpamFilter} onChange={setInviteSpamFilter} />}
className={SequenceCardStyle} />
variant="Background" <EmailNotification />
direction="Column" </SettingsSection>
gap="400"
>
<SettingTile
title={t('Settings.notification_sound')}
description={t('Settings.notification_sound_desc')}
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.invite_spam_filter')}
description={t('Settings.invite_spam_filter_desc')}
after={<Switch value={inviteSpamFilter} onChange={setInviteSpamFilter} />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<EmailNotification />
</SequenceCard>
</Box>
); );
} }

View file

@ -1,6 +1,46 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { config } from 'folds'; import { color, config, toRem } from 'folds';
export const SequenceCardStyle = style({ // Section label above a grouped panel — uppercase, tracked, muted. Matches the
padding: config.space.S300, // Dawn canon's "КОМАНДЫ" / "УЧАСТНИКИ · 28" panel headers (sans, not mono).
export const SectionLabel = style({
display: 'block',
fontSize: toRem(11),
lineHeight: toRem(16),
fontWeight: config.fontWeight.W600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: color.Surface.OnContainer,
opacity: 0.5,
paddingLeft: config.space.S100,
paddingRight: config.space.S100,
});
// Optional caption rendered under a section label (e.g. "Скрытые от чужих глаз").
export const SectionFootnote = style({
display: 'block',
fontSize: toRem(12),
lineHeight: toRem(16),
color: color.Surface.OnContainer,
opacity: 0.5,
paddingLeft: config.space.S100,
paddingRight: config.space.S100,
paddingTop: config.space.S100,
});
// One row inside a grouped panel. Slightly roomier than the old card so a
// switch/menu row lands at a comfortable ~44px without feeling cramped.
export const SettingRow = style({
paddingTop: config.space.S300,
paddingBottom: config.space.S300,
paddingLeft: config.space.S400,
paddingRight: config.space.S400,
});
// JetBrains Mono for technical values — mxid, device ids, version, tokens —
// the same stack the DM stream / bot surfaces use for handles & timestamps.
export const Mono = style({
fontFamily:
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontVariantNumeric: 'tabular-nums',
}); });

View file

@ -8,8 +8,6 @@ import {
useActiveTheme, useActiveTheme,
useSystemThemeKind, useSystemThemeKind,
} from '../hooks/useTheme'; } from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
export function UnAuthRouteThemeManager() { export function UnAuthRouteThemeManager() {
const systemThemeKind = useSystemThemeKind(); const systemThemeKind = useSystemThemeKind();
@ -30,7 +28,6 @@ export function UnAuthRouteThemeManager() {
export function AuthRouteThemeManager({ children }: { children: ReactNode }) { export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const activeTheme = useActiveTheme(); const activeTheme = useActiveTheme();
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
useEffect(() => { useEffect(() => {
document.body.className = ''; document.body.className = '';
@ -38,12 +35,11 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
document.body.classList.add(...activeTheme.classNames); document.body.classList.add(...activeTheme.classNames);
if (monochromeMode) { // Clear any leftover grayscale filter from the retired monochrome-mode
document.body.style.filter = 'grayscale(1)'; // setting so users who had it enabled aren't stuck in grayscale after the
} else { // toggle was removed from Settings.
document.body.style.filter = ''; document.body.style.filter = '';
} }, [activeTheme]);
}, [activeTheme, monochromeMode]);
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>; return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
} }

View file

@ -7,7 +7,6 @@ export type ThemeId = 'light-theme' | 'dark-theme';
export interface Settings { export interface Settings {
themeId?: ThemeId; themeId?: ThemeId;
useSystemTheme: boolean; useSystemTheme: boolean;
monochromeMode?: boolean;
isMarkdown: boolean; isMarkdown: boolean;
editorToolbar: boolean; editorToolbar: boolean;
twitterEmoji: boolean; twitterEmoji: boolean;
@ -39,7 +38,6 @@ const SYSTEM_TIME_FORMAT_CLEANUP_KEY = 'system-time-format-cleanup';
const defaultSettings: Settings = { const defaultSettings: Settings = {
themeId: undefined, themeId: undefined,
useSystemTheme: true, useSystemTheme: true,
monochromeMode: false,
isMarkdown: true, isMarkdown: true,
editorToolbar: false, editorToolbar: false,
twitterEmoji: false, twitterEmoji: false,