Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93e4bcef9 | ||
|
|
1af491cabb | ||
|
|
449d0c740e | ||
|
|
299da879fb | ||
|
|
f8fa1ca5ed | ||
|
|
5d15013a27 | ||
|
|
0d4f2e4a87 | ||
|
|
c907b0ba99 |
@@ -1,22 +1,31 @@
|
||||
- navigation "Settings sections":
|
||||
- button "General" [pressed]:
|
||||
- img
|
||||
- paragraph: General
|
||||
- button "Account":
|
||||
- button "Account" [pressed]:
|
||||
- img
|
||||
- paragraph: Account
|
||||
- button "Appearance":
|
||||
- img
|
||||
- paragraph: Appearance
|
||||
- button "Messages":
|
||||
- img
|
||||
- paragraph: Messages
|
||||
- button "Notifications":
|
||||
- img
|
||||
- paragraph: Notifications
|
||||
- button "Devices":
|
||||
- button "Calls & audio":
|
||||
- img
|
||||
- paragraph: Devices
|
||||
- button "Emojis & Stickers":
|
||||
- paragraph: Calls & audio
|
||||
- button "Privacy & security":
|
||||
- img
|
||||
- paragraph: Emojis & Stickers
|
||||
- button "Developer Tools":
|
||||
- paragraph: Privacy & security
|
||||
- button "Data & storage":
|
||||
- img
|
||||
- paragraph: Developer Tools
|
||||
- paragraph: Data & storage
|
||||
- button "Emojis & stickers":
|
||||
- img
|
||||
- paragraph: Emojis & stickers
|
||||
- button "Advanced":
|
||||
- img
|
||||
- paragraph: Advanced
|
||||
- button "About":
|
||||
- img
|
||||
- paragraph: About
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe('key upload recovery (#201)', () => {
|
||||
expect(rejected).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await banner.getByRole('button', { name: 'Details' }).click();
|
||||
await expect(banner).toContainText('Settings → Devices');
|
||||
await expect(banner).toContainText('Settings → Privacy & security');
|
||||
await expect(banner).toContainText('already exists');
|
||||
|
||||
await banner.getByRole('button', { name: /Fix: sign out/ }).click();
|
||||
|
||||
@@ -75,7 +75,9 @@ test.describe('security nudge (#110, #123)', () => {
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
|
||||
test('no key backup: "Set up key backup" opens Settings → Privacy & security', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
@@ -83,7 +85,7 @@ test.describe('security nudge (#110, #123)', () => {
|
||||
const nudge = strip(page);
|
||||
await expect(nudge).toContainText("Your encryption keys aren't backed up");
|
||||
await nudge.getByRole('button', { name: 'Set up' }).click();
|
||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
||||
await expect(page.getByText('Device verification').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { HS, PASSWORD, api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||
|
||||
// [Gitea #113] Sessions unused for 90+ days: a nudge (after the device-security
|
||||
// one), and Review opens Settings → Devices with them ticked for sign-out.
|
||||
// Synapse won't backdate last-seen, so the session list is served with one
|
||||
// real extra session made 120 days old.
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const strip = (page: Page, text: RegExp) => page.getByRole('status').filter({ hasText: text });
|
||||
|
||||
async function pastGrace(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}');
|
||||
localStorage.setItem(
|
||||
`lotus-security-nudge-${deviceId}`,
|
||||
JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }),
|
||||
);
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function settled(page: Page) {
|
||||
await page
|
||||
.getByText('Connecting...')
|
||||
.first()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.catch(() => undefined);
|
||||
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||
}
|
||||
|
||||
test.describe('stale sessions (#113)', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('nudge after the security one, Review ticks the old session, sign-out works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const user = await ensureUser(uniq('ss'));
|
||||
// Two extra sessions: one we'll make look 4 months old, one recent.
|
||||
const login = (device: string) =>
|
||||
api<{ device_id: string }>('POST', '/_matrix/client/v3/login', undefined, {
|
||||
type: 'm.login.password',
|
||||
identifier: { type: 'm.id.user', user: user.localpart },
|
||||
password: PASSWORD,
|
||||
device_id: device,
|
||||
initial_device_display_name: device,
|
||||
});
|
||||
await login('OLDLAPTOP');
|
||||
await login('RECENTPHONE');
|
||||
await page.route(/\/_matrix\/client\/v3\/devices(\?.*)?$/, async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = await res.json();
|
||||
for (const d of body.devices ?? []) {
|
||||
if (d.device_id === 'OLDLAPTOP') d.last_seen_ts = Date.now() - 120 * DAY;
|
||||
else d.last_seen_ts = Date.now() - DAY;
|
||||
}
|
||||
await route.fulfill({ response: res, json: body });
|
||||
});
|
||||
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
await settled(page);
|
||||
|
||||
// A fresh account has no key backup: that nudge comes first.
|
||||
const security = strip(page, /encryption keys/);
|
||||
await expect(security).toBeVisible({ timeout: 30_000 });
|
||||
await security.getByRole('button', { name: 'Not now' }).click();
|
||||
|
||||
const stale = strip(page, /a session you haven't used in 3 months/);
|
||||
await expect(stale).toBeVisible({ timeout: 30_000 });
|
||||
await stale.getByRole('button', { name: 'Review' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog').first();
|
||||
await expect(dialog.getByText('Inactive for 3+ months')).toHaveCount(1);
|
||||
await expect(dialog.getByText(/Logout from selected devices\. \(1 selected\)/)).toBeVisible();
|
||||
|
||||
// The sticky "Logout from selected devices" bar comes last.
|
||||
await dialog.getByRole('button', { name: 'Logout', exact: true }).last().click();
|
||||
await page.locator('input[name="passwordInput"]').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(dialog.getByText(/Logout from selected devices/)).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const { devices } = await api<{ devices: { device_id: string }[] }>(
|
||||
'GET',
|
||||
'/_matrix/client/v3/devices',
|
||||
user.token,
|
||||
);
|
||||
const ids = devices.map((d) => d.device_id);
|
||||
expect(ids).not.toContain('OLDLAPTOP');
|
||||
expect(ids).toContain('RECENTPHONE');
|
||||
expect(HS).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ test.describe('your data (#114)', () => {
|
||||
.catch(() => undefined);
|
||||
const openGeneral = async () => {
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
await page.getByRole('dialog').getByText('General', { exact: true }).first().click();
|
||||
await page.getByRole('dialog').getByText('Data & storage', { exact: true }).first().click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByText('Your data', { exact: true })
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useModalStyle } from '../hooks/useModalStyle';
|
||||
* cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat
|
||||
* should keep running in the tray (calls, messages, notifications) or quit —
|
||||
* and, in the same moment, whether it should start when you sign in. Asked
|
||||
* once; changeable in Settings → General. The native side sends
|
||||
* once; changeable in Settings → Desktop. The native side sends
|
||||
* `lotus-close-requested` only while the choice is still "ask", and never
|
||||
* during a call (a call always goes to the tray).
|
||||
*/
|
||||
@@ -149,7 +149,7 @@ export function CloseBehaviorPrompt() {
|
||||
)}
|
||||
</Box>
|
||||
<Text size="T200" priority="300">
|
||||
You can change this in Settings → General.
|
||||
You can change this in Settings → Desktop.
|
||||
</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
|
||||
>
|
||||
<SettingTile
|
||||
title="Message Retention"
|
||||
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
|
||||
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → Messages; full server-side deletion also requires homeserver retention to be configured."
|
||||
>
|
||||
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
|
||||
{RETENTION_PRESETS.map((preset) => {
|
||||
|
||||
@@ -128,7 +128,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
const editor = useEditor();
|
||||
const thread = useThreadInstance(room, threadId);
|
||||
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
|
||||
// "Hide typing and read receipts" must also make thread receipts private (matches markAsRead).
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@ import {
|
||||
nudgeFor,
|
||||
NudgeRecord,
|
||||
parseNudgeRecord,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from '../../utils/securityNudge';
|
||||
import { preselectSignOutAtom } from '../../state/staleSessions';
|
||||
import { StatusStrip } from '../server-status/ServerStatusBanner';
|
||||
|
||||
/** This device's backup connection: undefined until known, then true/false. */
|
||||
@@ -40,6 +43,35 @@ const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
|
||||
return active;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #113] Ids of this account's sessions unused for 90+ days. Fetched
|
||||
* once sync has settled, then hourly; [] until known or on error.
|
||||
*/
|
||||
const useStaleSessionIds = (enabled: boolean): string[] => {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const load = () =>
|
||||
mx
|
||||
.getDevices()
|
||||
.then(({ devices }) => {
|
||||
if (alive())
|
||||
setIds(
|
||||
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map(
|
||||
(d) => d.device_id,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
load();
|
||||
const t = window.setInterval(load, 60 * 60 * 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [mx, enabled, alive]);
|
||||
return ids;
|
||||
};
|
||||
|
||||
const readRecord = (key: string): NudgeRecord => {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
@@ -84,12 +116,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
let deviceVerified: boolean | undefined;
|
||||
if (status === VerificationStatus.Verified) deviceVerified = true;
|
||||
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
||||
const kind = nudgeFor({
|
||||
const securityKind = nudgeFor({
|
||||
crossSigning,
|
||||
deviceVerified,
|
||||
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
||||
backupActive,
|
||||
});
|
||||
const staleIds = useStaleSessionIds(settled);
|
||||
const setPreselect = useSetAtom(preselectSignOutAtom);
|
||||
const kind = pickNudge(securityKind, staleIds.length, record, Date.now());
|
||||
|
||||
// One strip at a time: outages, maintenance and a lost connection win.
|
||||
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
||||
@@ -97,10 +132,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
const otherStrip =
|
||||
syncLost ||
|
||||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||
return null;
|
||||
if (!settled || !deviceId || !kind || otherStrip) return null;
|
||||
|
||||
const copy = NUDGE_COPY[kind];
|
||||
const copy =
|
||||
kind === 'stale-sessions'
|
||||
? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) }
|
||||
: NUDGE_COPY[kind];
|
||||
const dismiss = () => {
|
||||
const next = dismissNudge(kind, record, Date.now());
|
||||
try {
|
||||
@@ -113,8 +150,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
return (
|
||||
<StatusStrip
|
||||
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
||||
icon={Icons.ShieldUser}
|
||||
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
||||
action={{
|
||||
label: copy.action,
|
||||
onClick: () => {
|
||||
// Review: the Devices page opens with these ticked for sign-out.
|
||||
if (kind === 'stale-sessions') setPreselect(staleIds);
|
||||
requestSettings('devices');
|
||||
},
|
||||
}}
|
||||
icon={kind === 'stale-sessions' ? Icons.Category : Icons.ShieldUser}
|
||||
dismissLabel="Not now"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,16 @@ import {
|
||||
Text,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { General } from './general';
|
||||
import {
|
||||
AdvancedDisplayOptions,
|
||||
AppearancePage,
|
||||
CallsPage,
|
||||
DataStoragePage,
|
||||
DesktopPage,
|
||||
MessagesPage,
|
||||
Privacy,
|
||||
} from './general';
|
||||
import { isTauri as isTauriEnv } from '../../hooks/useTauri';
|
||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { Account } from './account';
|
||||
@@ -45,43 +54,23 @@ type SettingsMenuItem = {
|
||||
|
||||
const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
page: SettingsPages.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.AccountPage,
|
||||
name: 'Account',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.NotificationPage,
|
||||
name: 'Notifications',
|
||||
icon: Icons.Bell,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.DevicesPage,
|
||||
name: 'Devices',
|
||||
icon: Icons.Monitor,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.AboutPage,
|
||||
name: 'About',
|
||||
icon: Icons.Info,
|
||||
},
|
||||
],
|
||||
() =>
|
||||
[
|
||||
{ page: SettingsPages.AccountPage, name: 'Account', icon: Icons.User },
|
||||
{ page: SettingsPages.AppearancePage, name: 'Appearance', icon: Icons.Sun },
|
||||
{ page: SettingsPages.MessagesPage, name: 'Messages', icon: Icons.Message },
|
||||
{ page: SettingsPages.NotificationPage, name: 'Notifications', icon: Icons.Bell },
|
||||
{ page: SettingsPages.CallsPage, name: 'Calls & audio', icon: Icons.Phone },
|
||||
{ page: SettingsPages.DevicesPage, name: 'Privacy & security', icon: Icons.ShieldUser },
|
||||
{ page: SettingsPages.DataStoragePage, name: 'Data & storage', icon: Icons.Download },
|
||||
// [Gitea #459] Desktop-only options: nothing to show in a browser.
|
||||
...(isTauriEnv()
|
||||
? [{ page: SettingsPages.DesktopPage, name: 'Desktop', icon: Icons.Monitor }]
|
||||
: []),
|
||||
{ page: SettingsPages.EmojisStickersPage, name: 'Emojis & stickers', icon: Icons.Smile },
|
||||
{ page: SettingsPages.DeveloperToolsPage, name: 'Advanced', icon: Icons.Terminal },
|
||||
{ page: SettingsPages.AboutPage, name: 'About', icon: Icons.Info },
|
||||
] as SettingsMenuItem[],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -102,7 +91,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
|
||||
if (initialPage) return initialPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
|
||||
});
|
||||
const menuItems = useSettingsMenuItems();
|
||||
|
||||
@@ -207,8 +196,20 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
{activePage === SettingsPages.GeneralPage && (
|
||||
<General requestClose={handlePageRequestClose} />
|
||||
{activePage === SettingsPages.AppearancePage && (
|
||||
<AppearancePage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.MessagesPage && (
|
||||
<MessagesPage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.CallsPage && (
|
||||
<CallsPage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DataStoragePage && (
|
||||
<DataStoragePage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DesktopPage && (
|
||||
<DesktopPage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.AccountPage && (
|
||||
<Account requestClose={handlePageRequestClose} />
|
||||
@@ -217,13 +218,21 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
<Notifications requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DevicesPage && (
|
||||
<Devices requestClose={handlePageRequestClose} />
|
||||
<Devices
|
||||
title="Privacy & security"
|
||||
before={<Privacy />}
|
||||
requestClose={handlePageRequestClose}
|
||||
/>
|
||||
)}
|
||||
{activePage === SettingsPages.EmojisStickersPage && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DeveloperToolsPage && (
|
||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||
<DeveloperTools
|
||||
title="Advanced"
|
||||
before={<AdvancedDisplayOptions />}
|
||||
requestClose={handlePageRequestClose}
|
||||
/>
|
||||
)}
|
||||
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
||||
</PageRoot>
|
||||
|
||||
@@ -205,7 +205,7 @@ export function About({ requestClose }: AboutProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Clear Cache & Reload"
|
||||
title="Clear cache and reload"
|
||||
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
|
||||
after={
|
||||
<Button
|
||||
|
||||
@@ -22,14 +22,14 @@ export function ContactInformation() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Contact Information</Text>
|
||||
<Text size="L400">Contact information</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile title="Email Address" description="Email address attached to your account.">
|
||||
<SettingTile title="Email address" description="Email address attached to your account.">
|
||||
<Box>
|
||||
{emailIds?.map((email) => (
|
||||
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
|
||||
|
||||
@@ -136,7 +136,7 @@ export function IgnoredUserList() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Blocked Users</Text>
|
||||
<Text size="L400">Blocked users</Text>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
@@ -145,8 +145,8 @@ export function IgnoredUserList() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select User"
|
||||
description="Prevent receiving messages or invites from user by adding their userId."
|
||||
title="Block someone"
|
||||
description="Stop someone's messages and invites reaching you. Enter their Matrix ID, e.g. @name:server."
|
||||
>
|
||||
<Box direction="Column" gap="300">
|
||||
<IgnoreUserInput userList={ignoredUsers} />
|
||||
|
||||
@@ -31,7 +31,7 @@ export function OidcManageAccount() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Account Management</Text>
|
||||
<Text size="L400">Account management</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -313,7 +313,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Display Name
|
||||
Display name
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
@@ -576,7 +576,7 @@ function ProfileStatus() {
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Status Message
|
||||
Status message
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
|
||||
@@ -102,6 +102,8 @@ export function ProfileDecoration() {
|
||||
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
// [Gitea #460] The catalog stays closed until "Change"; it was always open.
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
// Distinguish "confirmed no decoration" from "failed to load": a fetch
|
||||
// failure must not be shown as (and saved over) "None".
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
@@ -199,7 +201,7 @@ export function ProfileDecoration() {
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Avatar Decoration
|
||||
Avatar decoration
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
@@ -282,6 +284,20 @@ export function ProfileDecoration() {
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{!loadError && (
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
aria-expanded={browsing}
|
||||
onClick={() => setBrowsing((b) => !b)}
|
||||
>
|
||||
<Text size="B300">{browsing ? 'Done' : 'Change'}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{hasChanges && !loadError && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -324,73 +340,77 @@ export function ProfileDecoration() {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Search + category tabs. Only one category (or the search results)
|
||||
{browsing && (
|
||||
<>
|
||||
{/* Search + category tabs. Only one category (or the search results)
|
||||
is mounted at a time so the picker never loads the whole catalog. */}
|
||||
<Input
|
||||
aria-label="Search decorations"
|
||||
placeholder="Search decorations"
|
||||
value={search}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
/>
|
||||
{!query && (
|
||||
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
|
||||
{DECORATION_CATEGORIES.map((category) => (
|
||||
<Chip
|
||||
key={category.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category.id === activeCategory}
|
||||
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
|
||||
fill="Soft"
|
||||
outlined={category.id === activeCategory}
|
||||
radii="Pill"
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{category.label}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<div
|
||||
role={query ? undefined : 'tabpanel'}
|
||||
style={{
|
||||
maxHeight: 360,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<Text size="T300" priority="300">
|
||||
No decorations match “{search.trim()}”.
|
||||
</Text>
|
||||
) : (
|
||||
<Input
|
||||
aria-label="Search decorations"
|
||||
placeholder="Search decorations"
|
||||
value={search}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
/>
|
||||
{!query && (
|
||||
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
|
||||
{DECORATION_CATEGORIES.map((category) => (
|
||||
<Chip
|
||||
key={category.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category.id === activeCategory}
|
||||
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
|
||||
fill="Soft"
|
||||
outlined={category.id === activeCategory}
|
||||
radii="Pill"
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{category.label}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<div
|
||||
role={query ? undefined : 'tabpanel'}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
||||
gap: 20,
|
||||
padding: 2,
|
||||
maxHeight: 360,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{visible.map((d) => (
|
||||
<DecorationPreviewCell
|
||||
key={d.slug}
|
||||
slug={d.slug}
|
||||
name={d.name}
|
||||
selected={selected === d.slug}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
{visible.length === 0 ? (
|
||||
<Text size="T300" priority="300">
|
||||
No decorations match “{search.trim()}”.
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
||||
gap: 20,
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
{visible.map((d) => (
|
||||
<DecorationPreviewCell
|
||||
key={d.slug}
|
||||
slug={d.slug}
|
||||
name={d.name}
|
||||
selected={selected === d.slug}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useState, ReactNode } from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -18,8 +18,12 @@ import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
/** [Gitea #459] Shown as the page title (the menu entry's name). */
|
||||
title?: string;
|
||||
/** [Gitea #459] Sections shown above this page's own. */
|
||||
before?: ReactNode;
|
||||
};
|
||||
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
export function DeveloperTools({ requestClose, title, before }: DeveloperToolsProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [expand, setExpend] = useState(false);
|
||||
@@ -49,7 +53,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Developer Tools
|
||||
{title ?? 'Developer Tools'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
@@ -63,6 +67,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
{before}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<SequenceCard
|
||||
@@ -72,7 +77,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Enable Developer Tools"
|
||||
title="Enable developer tools"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -90,7 +95,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Access Token"
|
||||
title="Access token"
|
||||
description="Copy access token to clipboard."
|
||||
after={
|
||||
<Button
|
||||
|
||||
@@ -35,7 +35,7 @@ export function CryptoDiagnostics() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Crypto Diagnostics</Text>
|
||||
<Text size="L400">Crypto diagnostics</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
@@ -43,8 +43,8 @@ export function CryptoDiagnostics() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Crypto Diagnostics — captures E2EE error signatures this session"
|
||||
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
|
||||
title="Crypto diagnostics"
|
||||
description="Captures encryption error signatures from this session. Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
|
||||
after={
|
||||
<Box alignItems="Center" gap="200" shrink="No">
|
||||
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
|
||||
|
||||
@@ -137,7 +137,7 @@ function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceR
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
|
||||
<Text size="L400">Device Name</Text>
|
||||
<Text size="L400">Device name</Text>
|
||||
<Box gap="200">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -38,8 +38,12 @@ function DevicesPlaceholder() {
|
||||
|
||||
type DevicesProps = {
|
||||
requestClose: () => void;
|
||||
/** [Gitea #459] Shown as the page title (the menu entry's name). */
|
||||
title?: string;
|
||||
/** [Gitea #459] Sections shown above this page's own. */
|
||||
before?: ReactNode;
|
||||
};
|
||||
export function Devices({ requestClose }: DevicesProps) {
|
||||
export function Devices({ requestClose, title, before }: DevicesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const crypto = mx.getCrypto();
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
@@ -70,7 +74,7 @@ export function Devices({ requestClose }: DevicesProps) {
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Devices
|
||||
{title ?? 'Devices'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
@@ -84,6 +88,7 @@ export function Devices({ requestClose }: DevicesProps) {
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
{before}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Security</Text>
|
||||
<SequenceCard
|
||||
@@ -93,8 +98,8 @@ export function Devices({ requestClose }: DevicesProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Device Verification"
|
||||
description="To verify device identity and grant access to encrypted messages."
|
||||
title="Device verification"
|
||||
description="Verify this device so it can read your encrypted messages and others can trust it."
|
||||
after={
|
||||
<>
|
||||
<EnableVerification visible={!crossSigningActive} />
|
||||
|
||||
@@ -67,7 +67,7 @@ function ExportKeys() {
|
||||
{(match, doMatch, passRef, confPassRef) => (
|
||||
<>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">New Password</Text>
|
||||
<Text size="L400">New password</Text>
|
||||
<PasswordInput
|
||||
ref={passRef}
|
||||
name="passwordInput"
|
||||
@@ -82,7 +82,7 @@ function ExportKeys() {
|
||||
/>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">Confirm Password</Text>
|
||||
<Text size="L400">Confirm password</Text>
|
||||
<PasswordInput
|
||||
ref={confPassRef}
|
||||
style={{ color: match ? undefined : color.Critical.Main }}
|
||||
@@ -130,8 +130,8 @@ function ExportKeysTile() {
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Export Messages Data"
|
||||
description="Save password protected copy of encryption data on your device to decrypt messages later."
|
||||
title="Export encryption keys"
|
||||
description="Save a password-protected file of your encryption keys. Useful when moving to a device without key backup, or as an offline copy."
|
||||
after={
|
||||
<Box>
|
||||
<Button
|
||||
@@ -260,8 +260,8 @@ function ImportKeysTile() {
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Import Messages Data"
|
||||
description="Load password protected copy of encryption data from device to decrypt your messages."
|
||||
title="Import encryption keys"
|
||||
description="Load a password-protected encryption key file to read older encrypted messages on this device."
|
||||
after={
|
||||
<Box>
|
||||
{file ? (
|
||||
@@ -307,7 +307,7 @@ function ImportKeysTile() {
|
||||
export function LocalBackup() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Local Backup</Text>
|
||||
<Text size="L400">Encryption key backup file</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -15,6 +16,8 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||
import { withSearchParam } from '../../../pages/pathUtils';
|
||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { preselectSignOutAtom } from '../../../state/staleSessions';
|
||||
import { staleSessions } from '../../../utils/securityNudge';
|
||||
|
||||
type OtherDevicesProps = {
|
||||
devices: IMyDevice[];
|
||||
@@ -29,6 +32,23 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
|
||||
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||
|
||||
// [Gitea #113] The stale-sessions nudge's Review: tick those sessions for
|
||||
// sign-out (one confirm below). OIDC accounts sign out on the provider's
|
||||
// dashboard instead, so nothing is ticked there.
|
||||
const [preselect, setPreselect] = useAtom(preselectSignOutAtom);
|
||||
useEffect(() => {
|
||||
if (preselect.length === 0) return;
|
||||
if (!authMetadata) {
|
||||
const present = new Set(devices.map((d) => d.device_id));
|
||||
setDeleted(new Set(preselect.filter((id) => present.has(id))));
|
||||
}
|
||||
setPreselect([]);
|
||||
}, [preselect, setPreselect, devices, authMetadata]);
|
||||
|
||||
const staleIds = new Set(
|
||||
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map((d) => d.device_id),
|
||||
);
|
||||
|
||||
const handleDashboardOIDC = useCallback(() => {
|
||||
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||
if (!authUrl) return;
|
||||
@@ -115,7 +135,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Device Dashboard"
|
||||
title="Device dashboard"
|
||||
description="Manage your devices on OIDC dashboard."
|
||||
after={
|
||||
<Button
|
||||
@@ -132,8 +152,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
{devices
|
||||
{staleIds.size > 0 && (
|
||||
<Text size="T200" priority="300">
|
||||
{staleIds.size === 1
|
||||
? "1 session hasn't been used in 3 months or more (marked Inactive)."
|
||||
: `${staleIds.size} sessions haven't been used in 3 months or more (marked Inactive).`}{' '}
|
||||
Sign out the ones you no longer use.
|
||||
</Text>
|
||||
)}
|
||||
{[...devices]
|
||||
// [Gitea #113] Inactive (90+ days) sessions first, then most recent.
|
||||
.sort((d1, d2) => {
|
||||
const s1 = staleIds.has(d1.device_id) ? 0 : 1;
|
||||
const s2 = staleIds.has(d2.device_id) ? 0 : 1;
|
||||
if (s1 !== s2) return s1 - s2;
|
||||
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
||||
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
|
||||
})
|
||||
@@ -145,6 +177,11 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
{staleIds.has(device.device_id) && (
|
||||
<Text size="L400" style={{ color: 'inherit' }}>
|
||||
Inactive for 3+ months
|
||||
</Text>
|
||||
)}
|
||||
<DeviceTile
|
||||
device={device}
|
||||
deleted={deleted.has(device.device_id)}
|
||||
|
||||
@@ -423,7 +423,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
||||
return (
|
||||
<>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Favorite Packs</Text>
|
||||
<Text size="L400">Favourite packs</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
@@ -431,7 +431,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select Pack"
|
||||
title="Add a pack"
|
||||
description="Pick emojis and stickers pack from rooms to use in all rooms."
|
||||
after={
|
||||
<>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Default Pack</Text>
|
||||
<Text size="L400">Default pack</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { Box, Button, Text, color } from 'folds';
|
||||
import { Box, Button, RadioButton, Text, color } from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
@@ -224,22 +224,24 @@ export function YourDataSection() {
|
||||
here:
|
||||
</Text>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
<RadioButton
|
||||
name="your-data-conflicts"
|
||||
variant="Primary"
|
||||
size="300"
|
||||
checked={!preferFile}
|
||||
onChange={() => setPreferFile(false)}
|
||||
onClick={() => setPreferFile(false)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Keep mine
|
||||
</Text>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
<RadioButton
|
||||
name="your-data-conflicts"
|
||||
variant="Primary"
|
||||
size="300"
|
||||
checked={preferFile}
|
||||
onChange={() => setPreferFile(true)}
|
||||
onClick={() => setPreferFile(true)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Use the file's
|
||||
|
||||
@@ -82,7 +82,7 @@ export function AllMessagesNotifications() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">All Messages</Text>
|
||||
<Text size="L400">All messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Secondary" fill="Solid">
|
||||
@@ -97,7 +97,7 @@ export function AllMessagesNotifications() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="1-to-1 Chats"
|
||||
title="1-to-1 chats"
|
||||
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
@@ -108,7 +108,7 @@ export function AllMessagesNotifications() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="1-to-1 Chats (Encrypted)"
|
||||
title="1-to-1 chats (encrypted)"
|
||||
after={
|
||||
<AllMessagesModeSwitcher
|
||||
pushRules={pushRules}
|
||||
@@ -137,7 +137,7 @@ export function AllMessagesNotifications() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Rooms (Encrypted)"
|
||||
title="Rooms (encrypted)"
|
||||
after={
|
||||
<AllMessagesModeSwitcher
|
||||
pushRules={pushRules}
|
||||
|
||||
@@ -171,7 +171,7 @@ export function KeywordMessagesNotifications() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Keyword Messages</Text>
|
||||
<Text size="L400">Keyword messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Success" fill="Solid">
|
||||
@@ -186,7 +186,7 @@ export function KeywordMessagesNotifications() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select Keyword"
|
||||
title="Add a keyword"
|
||||
description="Set a notification preference for message containing given keyword."
|
||||
>
|
||||
<KeywordInput />
|
||||
|
||||
@@ -9,7 +9,6 @@ import { KeywordMessagesNotifications } from './KeywordMessages';
|
||||
import { PushRuleEditor } from './PushRuleEditor';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { settingsAtom, Settings } from '../../../state/settings';
|
||||
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
|
||||
|
||||
@@ -81,7 +80,7 @@ function NotificationPresets() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Quick Presets</Text>
|
||||
<Text size="L400">Quick presets</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
|
||||
{PRESETS.map((preset) => (
|
||||
@@ -150,17 +149,6 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
||||
<SpecialMessagesNotifications />
|
||||
<KeywordMessagesNotifications />
|
||||
<PushRuleEditor />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Block Messages</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile description='This option has been moved to "Account > Block Users" section.' />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
@@ -333,10 +333,15 @@ export function PushRuleEditor() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Advanced Push Rules</Text>
|
||||
<RuleSection title="Override Rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
||||
<Text size="L400">Advanced push rules</Text>
|
||||
{/* [Gitea #459] Matrix terms kept, with one line on what they are. */}
|
||||
<Text size="T200" priority="300">
|
||||
The raw Matrix notification rules behind the options above, checked in this order: override,
|
||||
room, sender, then underride. You rarely need to change these.
|
||||
</Text>
|
||||
<RuleSection title="Override rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
||||
<RuleSection
|
||||
title="Room Rules"
|
||||
title="Room rules"
|
||||
kind={PushRuleKind.RoomSpecific}
|
||||
rules={roomRules}
|
||||
addForm={
|
||||
@@ -348,7 +353,7 @@ export function PushRuleEditor() {
|
||||
}
|
||||
/>
|
||||
<RuleSection
|
||||
title="Sender Rules"
|
||||
title="Sender rules"
|
||||
kind={PushRuleKind.SenderSpecific}
|
||||
rules={senderRules}
|
||||
addForm={
|
||||
@@ -359,7 +364,7 @@ export function PushRuleEditor() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RuleSection title="Underride Rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
||||
<RuleSection title="Underride rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SpecialMessagesNotifications() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Special Messages</Text>
|
||||
<Text size="L400">Special messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Success" fill="Solid">
|
||||
|
||||
@@ -63,7 +63,7 @@ function EmailNotification() {
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Email Notification"
|
||||
title="Email notifications"
|
||||
description={
|
||||
<>
|
||||
{result && !result.email && (
|
||||
@@ -161,7 +161,7 @@ function PauseNotifications() {
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Pause Notifications"
|
||||
title="Pause notifications"
|
||||
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
|
||||
after={
|
||||
active ? (
|
||||
@@ -221,7 +221,7 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Desktop Notifications"
|
||||
title="Desktop notifications"
|
||||
description={
|
||||
notifPermission === 'denied' ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
@@ -230,7 +230,7 @@ export function SystemNotification() {
|
||||
: 'Notifications are not supported by the system.'}
|
||||
</Text>
|
||||
) : (
|
||||
<span>Show desktop notifications when message arrive.</span>
|
||||
<span>Show desktop notifications when messages arrive.</span>
|
||||
)
|
||||
}
|
||||
after={
|
||||
@@ -263,8 +263,8 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Notification Sound"
|
||||
description="Play sound when new message arrive."
|
||||
title="Notification sound"
|
||||
description="Play a sound when a new message arrives."
|
||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||
/>
|
||||
{isNotificationSounds && (
|
||||
@@ -353,7 +353,7 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Quiet Hours"
|
||||
title="Quiet hours"
|
||||
description="Notifications are silenced between these times. Handles overnight (e.g. 11:00 PM – 8:00 AM)."
|
||||
after={<Switch value={quietHoursEnabled} onChange={setQuietHoursEnabled} />}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
|
||||
* page without pulling the dialog into the entry chunk. */
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
// [Gitea #459] These replace the old catch-all General page.
|
||||
AppearancePage,
|
||||
MessagesPage,
|
||||
NotificationPage,
|
||||
CallsPage,
|
||||
/** "Privacy & security": privacy options plus devices and encryption. */
|
||||
DevicesPage,
|
||||
DataStoragePage,
|
||||
/** Desktop app only. */
|
||||
DesktopPage,
|
||||
EmojisStickersPage,
|
||||
/** "Advanced": developer tools and display plumbing. */
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function useSettingsSync(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual actions for Settings → General → Sync. `pushNow` stamps a fresh
|
||||
* Manual actions for Settings → Data & storage → Sync. `pushNow` stamps a fresh
|
||||
* snapshot of THIS device so it wins on every other device; `clearRemote`
|
||||
* blanks the account-data event (other devices ignore the invalid content and
|
||||
* keep their local settings until someone pushes again).
|
||||
|
||||
@@ -1015,13 +1015,13 @@ function TauriUpdateFeature() {
|
||||
dismissToast(UPDATE_PROGRESS_TOAST);
|
||||
if (!failure) return;
|
||||
// Short on purpose; the full explanation and a Download installer button
|
||||
// are in Settings → General → App Updates.
|
||||
// are in Settings → Desktop → App updates.
|
||||
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
|
||||
setToast({
|
||||
id: UPDATE_FAILED_TOAST,
|
||||
iconSrc: Icons.Warning,
|
||||
displayName: 'Update didn’t install',
|
||||
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App Updates.`,
|
||||
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → Desktop → App updates.`,
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
sticky: true,
|
||||
@@ -1062,7 +1062,7 @@ function TauriUpdateFeature() {
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
onClick: () => {
|
||||
if (viaPackage) requestSettings('general');
|
||||
if (viaPackage) requestSettings('desktop');
|
||||
else installFromToast();
|
||||
},
|
||||
sticky: true,
|
||||
|
||||
@@ -61,7 +61,7 @@ import { AutoDiscovery } from './AutoDiscovery';
|
||||
|
||||
// Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at
|
||||
// module load so it sees crypto warnings from the very first sync. Idempotent;
|
||||
// report download lives in Settings → Developer Tools → Crypto Diagnostics.
|
||||
// report download lives in Settings → Advanced → Crypto diagnostics.
|
||||
installCryptoDiagLog();
|
||||
|
||||
function ClientRootLoading() {
|
||||
|
||||
@@ -72,8 +72,8 @@ export function KeyUploadBanner() {
|
||||
rejects every new upload and other people can't start fresh encrypted sessions
|
||||
with you. This usually happens after the browser cleared this site's storage
|
||||
while you stayed signed in. Before signing out, make sure this device is verified or
|
||||
key backup is on (Settings → Devices), so your message history comes back when you
|
||||
sign in again.
|
||||
key backup is on (Settings → Privacy & security), so your message history comes back
|
||||
when you sign in again.
|
||||
</Text>
|
||||
{lastError && (
|
||||
<Text size="T200" align="Center" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
|
||||
@@ -180,7 +180,10 @@ export function SettingsTab() {
|
||||
useEffect(() => {
|
||||
if (!settingsRequest) return;
|
||||
const page = {
|
||||
general: SettingsPages.GeneralPage,
|
||||
// [Gitea #459] General is gone; its old deep links land on Appearance.
|
||||
general: SettingsPages.AppearancePage,
|
||||
appearance: SettingsPages.AppearancePage,
|
||||
desktop: SettingsPages.DesktopPage,
|
||||
account: SettingsPages.AccountPage,
|
||||
notifications: SettingsPages.NotificationPage,
|
||||
devices: SettingsPages.DevicesPage,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export type SettingsRequestPage = 'general' | 'account' | 'notifications' | 'devices';
|
||||
export type SettingsRequestPage =
|
||||
| 'general'
|
||||
| 'account'
|
||||
| 'notifications'
|
||||
| 'devices'
|
||||
| 'desktop'
|
||||
| 'appearance';
|
||||
|
||||
/**
|
||||
* Ask the sidebar to open User Settings at a page from anywhere in the app
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* [Gitea #113] Session ids the stale-sessions nudge's Review asked Settings →
|
||||
* Devices to tick for sign-out. Consumed (and cleared) by OtherDevices.
|
||||
*/
|
||||
export const preselectSignOutAtom = atom<string[]>([]);
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
parseNudgeRecord,
|
||||
SecurityState,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from './securityNudge';
|
||||
|
||||
const healthy: SecurityState = {
|
||||
@@ -79,3 +82,45 @@ test('stored record: round trip, and garbage starts a fresh grace period', () =>
|
||||
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
|
||||
}
|
||||
});
|
||||
|
||||
test('[#113] stale sessions: 90+ days unused, never the current one', () => {
|
||||
const now = Date.UTC(2026, 9, 25);
|
||||
const day = 86_400_000;
|
||||
const devices = [
|
||||
{ device_id: 'ME', last_seen_ts: now - 400 * day },
|
||||
{ device_id: 'OLD', last_seen_ts: now - 91 * day },
|
||||
{ device_id: 'EDGE', last_seen_ts: now - 90 * day },
|
||||
{ device_id: 'RECENT', last_seen_ts: now - 89 * day },
|
||||
{ device_id: 'UNKNOWN' },
|
||||
{ device_id: 'NULL', last_seen_ts: null },
|
||||
];
|
||||
assert.deepEqual(
|
||||
staleSessions(devices, 'ME', now).map((d) => d.device_id),
|
||||
['OLD', 'EDGE'],
|
||||
);
|
||||
});
|
||||
|
||||
test('[#113] wording follows the count', () => {
|
||||
assert.match(
|
||||
staleSessionsText(1),
|
||||
/^You have a session you haven't used in 3 months\. Signing it out/,
|
||||
);
|
||||
assert.match(
|
||||
staleSessionsText(3),
|
||||
/^You have 3 sessions you haven't used in 3 months\. Signing them out/,
|
||||
);
|
||||
});
|
||||
|
||||
test('[#113] pickNudge: security first, stale sessions when that one is snoozed or absent', () => {
|
||||
const now = 10 * 86_400_000;
|
||||
const fresh = { firstSeen: 0, dismissals: {} };
|
||||
assert.equal(pickNudge('verify-device', 2, fresh, now), 'verify-device');
|
||||
assert.equal(pickNudge(undefined, 2, fresh, now), 'stale-sessions');
|
||||
assert.equal(pickNudge(undefined, 0, fresh, now), undefined);
|
||||
const snoozed = dismissNudge('verify-device', fresh, now);
|
||||
assert.equal(pickNudge('verify-device', 2, snoozed, now), 'stale-sessions');
|
||||
const bothSnoozed = dismissNudge('stale-sessions', snoozed, now);
|
||||
assert.equal(pickNudge('verify-device', 2, bothSnoozed, now), undefined);
|
||||
// inside the 24 h grace period nothing shows
|
||||
assert.equal(pickNudge(undefined, 2, { firstSeen: now, dismissals: {} }, now), undefined);
|
||||
});
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
* - setup-backup: no key backup at all (includes accounts that never set up
|
||||
* cross-signing: the setup flow does both).
|
||||
*
|
||||
* - stale-sessions (#113, lowest priority): sessions of this account not used
|
||||
* for 90+ days; Review opens Settings → Privacy & security with them ticked to sign out.
|
||||
*
|
||||
* Anything not yet known (crypto still loading) → no nudge. Timing: not in
|
||||
* the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three
|
||||
* dismissals of a nudge stop it for good; a healthy device never sees one.
|
||||
*/
|
||||
|
||||
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup';
|
||||
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup' | 'stale-sessions';
|
||||
|
||||
export type SecurityState = {
|
||||
/** m.cross_signing.master account data present. */
|
||||
@@ -38,6 +41,51 @@ export const nudgeFor = (s: SecurityState): NudgeKind | undefined => {
|
||||
return s.backupActive ? undefined : 'connect-backup';
|
||||
};
|
||||
|
||||
/** [Gitea #113] A session counts as stale after this long unused. */
|
||||
export const STALE_SESSION_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type SessionLike = { device_id: string; last_seen_ts?: number | null };
|
||||
|
||||
/**
|
||||
* Pure: other sessions of this account not seen for STALE_SESSION_MS. Never
|
||||
* the current session; a session with no last-seen time is left alone (we
|
||||
* can't tell it's unused).
|
||||
*/
|
||||
export const staleSessions = <T extends SessionLike>(
|
||||
devices: T[],
|
||||
currentDeviceId: string | undefined,
|
||||
now: number,
|
||||
): T[] =>
|
||||
devices.filter(
|
||||
(d) =>
|
||||
d.device_id !== currentDeviceId &&
|
||||
typeof d.last_seen_ts === 'number' &&
|
||||
now - d.last_seen_ts >= STALE_SESSION_MS,
|
||||
);
|
||||
|
||||
/** "1 session" / "3 sessions you haven't used in 3 months". */
|
||||
export const staleSessionsText = (count: number): string =>
|
||||
`You have ${count === 1 ? 'a session' : `${count} sessions`} you haven't used in 3 months. Signing ${
|
||||
count === 1 ? 'it' : 'them'
|
||||
} out keeps your account safe if an old device is lost.`;
|
||||
|
||||
/**
|
||||
* The nudge to show: the device-security one first, then stale sessions, each
|
||||
* only if its own snooze allows (so a snoozed security nudge doesn't hide the
|
||||
* stale-sessions one, and vice versa).
|
||||
*/
|
||||
export const pickNudge = (
|
||||
security: NudgeKind | undefined,
|
||||
staleCount: number,
|
||||
record: NudgeRecord,
|
||||
now: number,
|
||||
): NudgeKind | undefined => {
|
||||
const candidates: NudgeKind[] = [];
|
||||
if (security) candidates.push(security);
|
||||
if (staleCount > 0) candidates.push('stale-sessions');
|
||||
return candidates.find((k) => shouldShowNudge(k, record, now));
|
||||
};
|
||||
|
||||
export const NUDGE_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||
export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const NUDGE_MAX_DISMISSALS = 3;
|
||||
@@ -87,6 +135,11 @@ export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
|
||||
text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.",
|
||||
action: 'Connect',
|
||||
},
|
||||
'stale-sessions': {
|
||||
// The text depends on the count: see staleSessionsText.
|
||||
text: '',
|
||||
action: 'Review',
|
||||
},
|
||||
'setup-backup': {
|
||||
text: "Your encryption keys aren't backed up. Set up key backup so you don't lose your encrypted messages if you log out or lose this device.",
|
||||
action: 'Set up',
|
||||
|
||||
Reference in New Issue
Block a user