Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
378b40fabe |
@@ -83,7 +83,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 }) => {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import fs from 'node:fs';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||
|
||||
// [Gitea #114] Export / import "your data": a round trip through the file, the
|
||||
// merge summary, and the keep-mine / use-the-file's choice.
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
test.describe('your data (#114)', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('export, change things, import merges with the chosen conflict rule', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
const user = await ensureUser(uniq('yd'));
|
||||
const put = (type: string, content: unknown) =>
|
||||
api(
|
||||
'PUT',
|
||||
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
|
||||
user.token,
|
||||
content,
|
||||
);
|
||||
const get = (type: string) =>
|
||||
api<Record<string, unknown>>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
|
||||
user.token,
|
||||
);
|
||||
const bm = (eventId: string) => ({ roomId: '!r:localhost', eventId, savedAt: 1 });
|
||||
await put('io.lotus.bookmarks', { bookmarks: [bm('$one'), bm('$two')] });
|
||||
await put('io.lotus.user_notes', { '@friend:localhost': 'met at the LAN party' });
|
||||
|
||||
await loginUI(page, user);
|
||||
await page
|
||||
.getByText('Connecting...')
|
||||
.first()
|
||||
.waitFor({ state: 'detached', timeout: 90_000 })
|
||||
.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('Your data', { exact: true })
|
||||
.scrollIntoViewIfNeeded();
|
||||
};
|
||||
await openGeneral();
|
||||
|
||||
// Export.
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Export', exact: true }).click(),
|
||||
]);
|
||||
const path = await download.path();
|
||||
const file = JSON.parse(fs.readFileSync(path!, 'utf8'));
|
||||
expect(file.format).toBe('lotus-chat-data');
|
||||
expect(file.accountData['io.lotus.bookmarks'].bookmarks).toHaveLength(2);
|
||||
expect(file.local.settings).toBeTruthy();
|
||||
|
||||
// Lose a bookmark, change the note.
|
||||
await put('io.lotus.bookmarks', { bookmarks: [bm('$one')] });
|
||||
await put('io.lotus.user_notes', { '@friend:localhost': 'changed my mind' });
|
||||
await page.waitForTimeout(3000); // let sync deliver the new account data
|
||||
|
||||
const importFile = async () => {
|
||||
await page
|
||||
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
|
||||
.setInputFiles(path!);
|
||||
await expect(page.getByRole('region', { name: 'Import summary' })).toBeVisible();
|
||||
};
|
||||
await importFile();
|
||||
const summary = page.getByRole('region', { name: 'Import summary' });
|
||||
await expect(summary).toContainText('Bookmarks: 1 new');
|
||||
await expect(summary).toContainText('Private notes about people: 1 differ');
|
||||
|
||||
// Keep mine: the bookmark comes back, the note stays as it is now.
|
||||
await summary.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
|
||||
expect(((await get('io.lotus.bookmarks')).bookmarks as unknown[]).length).toBe(2);
|
||||
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('changed my mind');
|
||||
|
||||
// Again, using the file's: the note goes back.
|
||||
await page.waitForTimeout(2000);
|
||||
await importFile();
|
||||
await summary.getByText("Use the file's").click();
|
||||
await summary.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
|
||||
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('met at the LAN party');
|
||||
|
||||
// Wrong files are refused with a plain message.
|
||||
fs.writeFileSync(`${path}.bad`, '{"format":"something-else"}');
|
||||
await page
|
||||
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
|
||||
.setInputFiles(`${path}.bad`);
|
||||
await expect(page.getByText("This isn't a Lotus Chat data file.")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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 and read receipts" must also make thread receipts private (matches markAsRead).
|
||||
// "Hide Typing & 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,11 +23,8 @@ import {
|
||||
nudgeFor,
|
||||
NudgeRecord,
|
||||
parseNudgeRecord,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
shouldShowNudge,
|
||||
} 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. */
|
||||
@@ -43,35 +40,6 @@ 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 {
|
||||
@@ -116,15 +84,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
let deviceVerified: boolean | undefined;
|
||||
if (status === VerificationStatus.Verified) deviceVerified = true;
|
||||
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
||||
const securityKind = nudgeFor({
|
||||
const kind = 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
|
||||
@@ -132,12 +97,10 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
const otherStrip =
|
||||
syncLost ||
|
||||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||
if (!settled || !deviceId || !kind || otherStrip) return null;
|
||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||
return null;
|
||||
|
||||
const copy =
|
||||
kind === 'stale-sessions'
|
||||
? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) }
|
||||
: NUDGE_COPY[kind];
|
||||
const copy = NUDGE_COPY[kind];
|
||||
const dismiss = () => {
|
||||
const next = dismissNudge(kind, record, Date.now());
|
||||
try {
|
||||
@@ -150,15 +113,8 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
return (
|
||||
<StatusStrip
|
||||
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
||||
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}
|
||||
icon={Icons.ShieldUser}
|
||||
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
||||
dismissLabel="Not now"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
|
||||
@@ -205,7 +205,7 @@ export function About({ requestClose }: AboutProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Clear cache and reload"
|
||||
title="Clear Cache & 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="Block someone"
|
||||
description="Stop someone's messages and invites reaching you. Enter their Matrix ID, e.g. @name:server."
|
||||
title="Select User"
|
||||
description="Prevent receiving messages or invites from user by adding their userId."
|
||||
>
|
||||
<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"
|
||||
|
||||
@@ -72,7 +72,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Enable developer tools"
|
||||
title="Enable Developer Tools"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -90,7 +90,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"
|
||||
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."
|
||||
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."
|
||||
after={
|
||||
<Box alignItems="Center" gap="200" shrink="No">
|
||||
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
|
||||
|
||||
@@ -93,8 +93,8 @@ export function Devices({ requestClose }: DevicesProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Device verification"
|
||||
description="Verify this device so it can read your encrypted messages and others can trust it."
|
||||
title="Device Verification"
|
||||
description="To verify device identity and grant access to encrypted messages."
|
||||
after={
|
||||
<>
|
||||
<EnableVerification visible={!crossSigningActive} />
|
||||
|
||||
@@ -130,8 +130,8 @@ function ExportKeysTile() {
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
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."
|
||||
title="Export Messages Data"
|
||||
description="Save password protected copy of encryption data on your device to decrypt messages later."
|
||||
after={
|
||||
<Box>
|
||||
<Button
|
||||
@@ -260,8 +260,8 @@ function ImportKeysTile() {
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Import encryption keys"
|
||||
description="Load a password-protected encryption key file to read older encrypted messages on this device."
|
||||
title="Import Messages Data"
|
||||
description="Load password protected copy of encryption data from device to decrypt your messages."
|
||||
after={
|
||||
<Box>
|
||||
{file ? (
|
||||
@@ -307,7 +307,7 @@ function ImportKeysTile() {
|
||||
export function LocalBackup() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Encryption key backup file</Text>
|
||||
<Text size="L400">Local Backup</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -16,8 +15,6 @@ 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[];
|
||||
@@ -32,23 +29,6 @@ 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;
|
||||
@@ -135,7 +115,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
|
||||
@@ -152,20 +132,8 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
{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.
|
||||
{devices
|
||||
.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;
|
||||
})
|
||||
@@ -177,11 +145,6 @@ 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">Favourite packs</Text>
|
||||
<Text size="L400">Favorite Packs</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
@@ -431,7 +431,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Add a pack"
|
||||
title="Select 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"
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
} from '../../../utils/lotusDenoiseUtils';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { useSettingsSyncActions } from '../../../hooks/useSettingsSync';
|
||||
import { YourDataSection } from './YourData';
|
||||
import {
|
||||
CallAudioBitrate,
|
||||
ChatBackground,
|
||||
@@ -155,7 +156,7 @@ function DesktopChromeSetting() {
|
||||
return (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Custom window chrome (beta)"
|
||||
title="Custom Window Chrome (Beta)"
|
||||
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
|
||||
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
|
||||
/>
|
||||
@@ -263,7 +264,7 @@ function KeychainMirrorSetting() {
|
||||
}
|
||||
return (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Keep your sign-in in the system keychain" description={description} />
|
||||
<SettingTile title="Login in the system keychain" description={description} />
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
@@ -294,18 +295,12 @@ const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||||
),
|
||||
);
|
||||
|
||||
function SelectTheme({ disabled, kind }: { disabled?: boolean; kind?: ThemeKind }) {
|
||||
const allThemes = useThemes();
|
||||
const themes = kind ? allThemes.filter((theme) => theme.kind === kind) : allThemes;
|
||||
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
const themes = useThemes();
|
||||
const themeNames = useThemeNames();
|
||||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||||
const [, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||
const [, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const selectedTheme =
|
||||
themes.find((theme) => theme.id === themeId) ??
|
||||
themes[0] ??
|
||||
(kind === ThemeKind.Dark ? DarkTheme : LightTheme);
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||||
|
||||
const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
@@ -313,10 +308,6 @@ function SelectTheme({ disabled, kind }: { disabled?: boolean; kind?: ThemeKind
|
||||
|
||||
const handleThemeSelect = (theme: Theme) => {
|
||||
setThemeId(theme.id);
|
||||
// [Gitea #455] Also remember it as this kind's variant, so switching to
|
||||
// System later keeps the user's pick.
|
||||
if (theme.kind === ThemeKind.Light) setLightThemeId(theme.id);
|
||||
else setDarkThemeId(theme.id);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
@@ -401,7 +392,7 @@ function SystemThemePreferences() {
|
||||
return (
|
||||
<Box wrap="Wrap" gap="400">
|
||||
<SettingTile
|
||||
title="Light theme:"
|
||||
title="Light Theme:"
|
||||
after={
|
||||
<Chip
|
||||
variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
|
||||
@@ -442,7 +433,7 @@ function SystemThemePreferences() {
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Dark theme:"
|
||||
title="Dark Theme:"
|
||||
after={
|
||||
<Chip
|
||||
variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
|
||||
@@ -544,26 +535,6 @@ function PageZoomInput() {
|
||||
|
||||
function Appearance() {
|
||||
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||||
const allThemes = useThemes();
|
||||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||||
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
const currentKind = allThemes.find((t) => t.id === themeId)?.kind ?? ThemeKind.Light;
|
||||
let themeMode: 'system' | 'light' | 'dark' = 'system';
|
||||
if (!systemTheme) themeMode = currentKind === ThemeKind.Dark ? 'dark' : 'light';
|
||||
const chooseThemeMode = (mode: 'system' | 'light' | 'dark') => {
|
||||
if (mode === 'system') {
|
||||
setSystemTheme(true);
|
||||
return;
|
||||
}
|
||||
setSystemTheme(false);
|
||||
// Use the variant already chosen for that kind (or the default one).
|
||||
const kind = mode === 'dark' ? ThemeKind.Dark : ThemeKind.Light;
|
||||
if (allThemes.find((t) => t.id === themeId)?.kind === kind) return;
|
||||
const preferred = mode === 'dark' ? darkThemeId : lightThemeId;
|
||||
const fallback = mode === 'dark' ? DarkTheme.id : LightTheme.id;
|
||||
setThemeId(allThemes.find((t) => t.id === preferred && t.kind === kind)?.id ?? fallback);
|
||||
};
|
||||
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
|
||||
@@ -605,64 +576,38 @@ function Appearance() {
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
{/* [Gitea #455] One control instead of a System switch plus a Theme
|
||||
dropdown that was greyed out whenever the switch was on. */}
|
||||
<SettingTile
|
||||
title="Theme"
|
||||
description={
|
||||
themeMode === 'system'
|
||||
? "Follows your device's light or dark setting."
|
||||
: 'Always use this theme, whatever your device is set to.'
|
||||
}
|
||||
after={
|
||||
<Box gap="100" role="radiogroup" aria-label="Theme" shrink="No" wrap="Wrap">
|
||||
{(
|
||||
[
|
||||
['system', 'System'],
|
||||
['light', 'Light'],
|
||||
['dark', 'Dark'],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<Chip
|
||||
key={mode}
|
||||
role="radio"
|
||||
aria-checked={themeMode === mode}
|
||||
variant={themeMode === mode ? 'Primary' : 'Secondary'}
|
||||
outlined={themeMode === mode}
|
||||
radii="Pill"
|
||||
onClick={() => chooseThemeMode(mode)}
|
||||
>
|
||||
<Text size="B300">{label}</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
title="System Theme"
|
||||
description="Choose between light and dark theme based on system preference."
|
||||
after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
|
||||
/>
|
||||
{themeMode === 'system' && <SystemThemePreferences />}
|
||||
{themeMode !== 'system' && (
|
||||
<SettingTile
|
||||
title={themeMode === 'light' ? 'Light theme:' : 'Dark theme:'}
|
||||
after={<SelectTheme kind={themeMode === 'light' ? ThemeKind.Light : ThemeKind.Dark} />}
|
||||
/>
|
||||
)}
|
||||
{systemTheme && <SystemThemePreferences />}
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Monochrome mode"
|
||||
title="Theme"
|
||||
description="Theme to use when system theme is not enabled."
|
||||
after={<SelectTheme disabled={systemTheme} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Monochrome Mode"
|
||||
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Emoji style: Twemoji"
|
||||
title="Twitter Emoji"
|
||||
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Page zoom" after={<PageZoomInput />} />
|
||||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard
|
||||
@@ -672,7 +617,7 @@ function Appearance() {
|
||||
gap="200"
|
||||
>
|
||||
<SettingTile
|
||||
title="Chat background"
|
||||
title="Chat Background"
|
||||
description="Pattern applied behind the message timeline."
|
||||
/>
|
||||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||||
@@ -682,7 +627,7 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Pause background animations"
|
||||
title="Pause Background Animations"
|
||||
description="Stop background animation to reduce motion or improve performance."
|
||||
after={<Switch variant="Primary" value={pauseAnimations} onChange={setPauseAnimations} />}
|
||||
/>
|
||||
@@ -695,7 +640,7 @@ function Appearance() {
|
||||
gap="200"
|
||||
>
|
||||
<SettingTile
|
||||
title="Seasonal theme"
|
||||
title="Seasonal Theme"
|
||||
description="Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
|
||||
/>
|
||||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||||
@@ -715,7 +660,7 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show name and avatar on every message"
|
||||
title="Show Profile on Every Message"
|
||||
description="Display avatar and name on each message instead of grouping consecutive messages."
|
||||
after={
|
||||
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
|
||||
@@ -729,7 +674,7 @@ function Appearance() {
|
||||
gap="200"
|
||||
>
|
||||
<SettingTile
|
||||
title="Night light"
|
||||
title="Night Light"
|
||||
description="Reduce blue light with a warm orange tint overlay."
|
||||
after={
|
||||
<Switch variant="Primary" value={nightLightEnabled} onChange={setNightLightEnabled} />
|
||||
@@ -809,7 +754,7 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Glass sidebar"
|
||||
title="Glassmorphism Sidebar"
|
||||
description="Semi-transparent sidebar with frosted glass effect. Works best with custom chat backgrounds."
|
||||
after={
|
||||
<Switch
|
||||
@@ -823,8 +768,8 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Lotus Terminal mode"
|
||||
description="A retro terminal look: orange and cyan on a dot grid, CRT scanlines and a boot animation (press Esc to skip)."
|
||||
title="Lotus Terminal Mode"
|
||||
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
|
||||
after={
|
||||
<Box direction="Row" gap="200" alignItems="Center">
|
||||
{lotusTerminal && (
|
||||
@@ -858,7 +803,7 @@ function Appearance() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Interface font"
|
||||
title="UI Font"
|
||||
description="Font used throughout the interface."
|
||||
after={
|
||||
<SettingsSelect
|
||||
@@ -878,7 +823,7 @@ function Appearance() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="@mention highlight colour"
|
||||
title="@Mention Highlight Color"
|
||||
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
|
||||
after={
|
||||
<HexColorPickerPopOut
|
||||
@@ -922,7 +867,7 @@ function Appearance() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Custom accent colour"
|
||||
title="Custom Accent Color"
|
||||
description={
|
||||
lotusTerminal
|
||||
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
|
||||
@@ -1296,7 +1241,7 @@ function SelectDateFormat() {
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Date format"
|
||||
title="Date Format"
|
||||
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
|
||||
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
|
||||
/>
|
||||
@@ -1312,10 +1257,10 @@ function DateAndTime() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Date and time</Text>
|
||||
<Text size="L400">Date & Time</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="24-hour time"
|
||||
title="24-Hour Time Format"
|
||||
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
@@ -1527,7 +1472,7 @@ function Editor() {
|
||||
<Text size="L400">Editor</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Enter for a new line"
|
||||
title="ENTER for Newline"
|
||||
description={`Use ${
|
||||
isMacOS() ? KeySymbol.Command : 'Ctrl'
|
||||
} + ENTER to send message and ENTER for newline.`}
|
||||
@@ -1536,20 +1481,20 @@ function Editor() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Markdown formatting"
|
||||
title="Markdown Formatting"
|
||||
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Offer to format pasted code"
|
||||
title="Offer to Format Pasted Code"
|
||||
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
|
||||
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Formatting toolbar"
|
||||
title="Formatting Toolbar"
|
||||
description="Show bold, italic, code and other formatting buttons above the message input."
|
||||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||||
/>
|
||||
@@ -1561,7 +1506,7 @@ function Editor() {
|
||||
gap="300"
|
||||
>
|
||||
<SettingTile
|
||||
title="Composer toolbar"
|
||||
title="Composer Toolbar"
|
||||
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
|
||||
/>
|
||||
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
||||
@@ -1596,28 +1541,28 @@ function Privacy() {
|
||||
<Text size="L400">Privacy</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Strip tracking parameters from links"
|
||||
title="Strip Tracking Parameters from Links"
|
||||
description="Remove utm_, fbclid, YouTube si= and other ad/analytics identifiers from links you paste or send, and from links shown in chat. Runs entirely on this device."
|
||||
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Remove photo metadata before sending"
|
||||
title="Remove Photo Metadata Before Sending"
|
||||
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
|
||||
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide typing and read receipts"
|
||||
title="Hide Typing & Read Receipts"
|
||||
description="Turn off both typing status and read receipts to keep your activity private."
|
||||
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Private read receipts"
|
||||
title="Private Read Receipts"
|
||||
description="Send read receipts only to you and the server — other users won't see when you've read messages."
|
||||
after={
|
||||
<Switch
|
||||
@@ -1630,7 +1575,7 @@ function Privacy() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide online status"
|
||||
title="Hide Online Status"
|
||||
description="Appear offline to everyone while still using the app. Disables online, away, and idle indicators."
|
||||
after={<Switch variant="Primary" value={hidePresence} onChange={setHidePresence} />}
|
||||
/>
|
||||
@@ -1712,7 +1657,7 @@ function SettingsSyncSection() {
|
||||
<Text size="L400">Sync</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Sync settings across devices"
|
||||
title="Sync Settings Across Devices"
|
||||
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
|
||||
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
|
||||
/>
|
||||
@@ -1720,7 +1665,7 @@ function SettingsSyncSection() {
|
||||
{settingsSync && (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Use this device's settings everywhere"
|
||||
title="Use This Device's Settings Everywhere"
|
||||
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
||||
// [Gitea #273] On phones the two buttons squeezed the text into a
|
||||
// narrow column; put them under the description there.
|
||||
@@ -1855,14 +1800,14 @@ function Calls() {
|
||||
<Text size="L400">Calls</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Join with camera on"
|
||||
title="Join with Camera On"
|
||||
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
|
||||
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Noise suppression"
|
||||
title="Noise Suppression"
|
||||
description={
|
||||
<Box direction="Column" gap="200">
|
||||
{/* [Gitea #271] T200 like every other setting description. */}
|
||||
@@ -1919,7 +1864,7 @@ function Calls() {
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Model</Text>
|
||||
<SettingTile
|
||||
title="ML model"
|
||||
title="ML Model"
|
||||
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
|
||||
after={
|
||||
<SettingsSelect<DenoiseModelId>
|
||||
@@ -2035,7 +1980,7 @@ function Calls() {
|
||||
>
|
||||
<Text size="L400">Enhancements</Text>
|
||||
<SettingTile
|
||||
title="Also use the browser's noise suppression"
|
||||
title="Series Suppression"
|
||||
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
|
||||
after={
|
||||
<Switch
|
||||
@@ -2047,7 +1992,7 @@ function Calls() {
|
||||
/>
|
||||
|
||||
<SettingTile
|
||||
title="Noise gate"
|
||||
title="Noise Gate"
|
||||
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
|
||||
after={
|
||||
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
|
||||
@@ -2083,7 +2028,7 @@ function Calls() {
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Text size="L400">Test and calibrate</Text>
|
||||
<Text size="L400">Test & calibrate</Text>
|
||||
<Text size="T200" priority="300">
|
||||
Audition the selected model and tune the gate without joining a call. Changes to the
|
||||
settings above apply the next time you start a monitor or play a clip.
|
||||
@@ -2105,7 +2050,7 @@ function Calls() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Rejoin calls after a restart"
|
||||
title="After a Restart"
|
||||
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
|
||||
after={
|
||||
<SettingsSelect<'ask' | 'auto' | 'off'>
|
||||
@@ -2120,7 +2065,7 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Summary after calls"
|
||||
title="Summary After Calls"
|
||||
description="When a call ends, show how long it was and how the connection held up, with a Details button for who was there, network numbers and your own talk/mute time."
|
||||
after={
|
||||
<Switch variant="Primary" value={callSummaryToast} onChange={setCallSummaryToast} />
|
||||
@@ -2128,19 +2073,19 @@ function Calls() {
|
||||
/>
|
||||
{hapticsSupported() && (
|
||||
<SettingTile
|
||||
title="Haptic feedback"
|
||||
title="Haptic Feedback"
|
||||
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
|
||||
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to talk"
|
||||
title="Push to Talk"
|
||||
description="Mute your microphone by default. Hold the PTT key to speak."
|
||||
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
|
||||
/>
|
||||
{pttMode && (
|
||||
<SettingTile
|
||||
title="Push-to-talk key"
|
||||
title="PTT Key"
|
||||
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
|
||||
after={
|
||||
<Button
|
||||
@@ -2158,7 +2103,7 @@ function Calls() {
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to deafen"
|
||||
title="Push to Deafen"
|
||||
description={
|
||||
deafenBind.error ??
|
||||
(deafenHotkey
|
||||
@@ -2199,7 +2144,7 @@ function Calls() {
|
||||
/>
|
||||
{isTauriEnv() && (
|
||||
<SettingTile
|
||||
title="Hotkeys work outside the window"
|
||||
title="Hotkeys Work Outside the Window"
|
||||
description="Keep Push to Talk and Push to Deafen working while a game or another app has focus. The keys are only watched during a call and are never blocked from other apps. Windows only; some anti-cheat systems may prevent it."
|
||||
after={
|
||||
<Switch variant="Primary" value={globalCallHotkeys} onChange={setGlobalCallHotkeys} />
|
||||
@@ -2221,13 +2166,13 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="AFK auto-mute"
|
||||
title="AFK Auto-Mute"
|
||||
description="Automatically mute your microphone when you have been silent in a call."
|
||||
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
|
||||
/>
|
||||
{afkAutoMute && (
|
||||
<SettingTile
|
||||
title="Idle timeout"
|
||||
title="Idle Timeout"
|
||||
description="How long to wait before auto-muting."
|
||||
after={
|
||||
<SettingsSelect
|
||||
@@ -2259,7 +2204,7 @@ function Calls() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Ringtone volume" description="Volume of the incoming call ringtone." />
|
||||
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
|
||||
<Box
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
@@ -2283,7 +2228,7 @@ function Calls() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Join and leave sounds"
|
||||
title="Join & Leave Sounds"
|
||||
description="Play a sound when someone joins or leaves a call you are in. Selecting an option plays a preview."
|
||||
after={
|
||||
<SettingsSelect
|
||||
@@ -2302,7 +2247,7 @@ function Calls() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Microphone bitrate"
|
||||
title="Microphone Bitrate"
|
||||
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
|
||||
after={
|
||||
<SettingsSelect<CallAudioBitrate>
|
||||
@@ -2316,7 +2261,7 @@ function Calls() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Screen share bitrate"
|
||||
title="Screenshare Bitrate"
|
||||
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
|
||||
after={
|
||||
<SettingsSelect<ScreenshareBitrate>
|
||||
@@ -2330,7 +2275,7 @@ function Calls() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Screen share frame rate"
|
||||
title="Screenshare Framerate"
|
||||
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
|
||||
after={
|
||||
<SettingsSelect<ScreenshareFramerate>
|
||||
@@ -2356,7 +2301,7 @@ function Calls() {
|
||||
/>
|
||||
{soundboardEnabled && (
|
||||
<>
|
||||
<SettingTile title="Soundboard volume" />
|
||||
<SettingTile title="Soundboard Volume" />
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
@@ -2701,14 +2646,14 @@ function Messages() {
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Messages</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Message layout" after={<SelectMessageLayout />} />
|
||||
<SettingTile title="Message Layout" after={<SelectMessageLayout />} />
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Message spacing" after={<SelectMessageSpacing />} />
|
||||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Legacy username colour"
|
||||
title="Legacy Username Color"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -2720,7 +2665,7 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide joins and leaves"
|
||||
title="Hide Membership Change"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -2732,7 +2677,7 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide name and avatar changes"
|
||||
title="Hide Profile Change"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -2744,7 +2689,7 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Don't load media automatically"
|
||||
title="Disable Media Auto Load"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -2756,14 +2701,14 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Link previews"
|
||||
title="URL Preview"
|
||||
description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
|
||||
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Link previews in encrypted rooms"
|
||||
title="URL Preview in Encrypted Rooms"
|
||||
description={
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200" priority="300">
|
||||
@@ -2787,29 +2732,34 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Inline media players"
|
||||
title="Inline Media Players"
|
||||
description="Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
|
||||
after={
|
||||
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{/* [Gitea #457] Only where this build has a GIF key: a disabled switch
|
||||
with a paragraph about missing configuration helps nobody. */}
|
||||
{gifAvailable && (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="GIF picker"
|
||||
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
|
||||
after={
|
||||
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show hidden events"
|
||||
title="GIF Picker"
|
||||
description={
|
||||
gifAvailable
|
||||
? 'Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default.'
|
||||
: "GIF search isn't set up for this version of the app (no Giphy key in its configuration), so there is no GIF button even when this is on."
|
||||
}
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={gifPickerEnabled && gifAvailable}
|
||||
onChange={setGifPickerEnabled}
|
||||
disabled={!gifAvailable}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Hidden Events"
|
||||
after={
|
||||
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
|
||||
}
|
||||
@@ -2817,7 +2767,7 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Enforce message retention"
|
||||
title="Enforce Message Retention"
|
||||
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
|
||||
after={
|
||||
<Switch
|
||||
@@ -2834,7 +2784,7 @@ function Messages() {
|
||||
direction="Column"
|
||||
>
|
||||
<SettingTile
|
||||
title="Translate messages into"
|
||||
title="Translate Messages Into"
|
||||
description={
|
||||
translationSupported
|
||||
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
|
||||
@@ -2852,7 +2802,7 @@ function Messages() {
|
||||
/>
|
||||
{translationSupported && (
|
||||
<SettingTile
|
||||
title="Auto-translate incoming messages"
|
||||
title="Auto-translate Incoming Messages"
|
||||
description="Automatically translate messages that aren’t already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
|
||||
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
|
||||
/>
|
||||
@@ -3013,9 +2963,9 @@ function AppUpdates() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">App updates</Text>
|
||||
<Text size="L400">App Updates</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Check for updates" description={description} after={after} />
|
||||
<SettingTile title="Check for Updates" description={description} after={after} />
|
||||
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
|
||||
{status.state === 'error' && !packageUpdate && (
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
@@ -3058,6 +3008,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Privacy />
|
||||
<StorageUsage />
|
||||
<SettingsSyncSection />
|
||||
<YourDataSection />
|
||||
<Calls />
|
||||
<DesktopSettings />
|
||||
<AppUpdates />
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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 { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { Settings, settingsAtom } from '../../../state/settings';
|
||||
import {
|
||||
buildExport,
|
||||
EXPORT_ACCOUNT_DATA,
|
||||
ExportFile,
|
||||
ImportPlanItem,
|
||||
mergeData,
|
||||
parseExport,
|
||||
planImport,
|
||||
} from '../../../utils/dataExport';
|
||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const DECORATION_FIELD = 'io.lotus.avatar_decoration';
|
||||
|
||||
type Current = {
|
||||
accountData: Record<string, unknown>;
|
||||
decoration: string | null;
|
||||
localSettings: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #114] "Your data": export the account's Lotus data (settings,
|
||||
* bookmarks, notes, sections, ...) to a file and import it here or on another
|
||||
* account. Import merges: new things are added; for things that differ the
|
||||
* user picks "keep mine" (default) or "use the file's". Message history and
|
||||
* encryption keys have their own exports.
|
||||
*/
|
||||
export function YourDataSection() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getSafeUserId();
|
||||
const [settings, setSettings] = useAtom(settingsAtom);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [pending, setPending] = useState<{ file: ExportFile; plan: ImportPlanItem[] }>();
|
||||
const [preferFile, setPreferFile] = useState(false);
|
||||
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string }>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const readCurrent = useCallback(async (): Promise<Current> => {
|
||||
const accountData: Record<string, unknown> = {};
|
||||
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
|
||||
const c = mx.getAccountData(type as never)?.getContent();
|
||||
if (c && Object.keys(c).length > 0) accountData[type] = c;
|
||||
});
|
||||
let decoration: string | null = null;
|
||||
try {
|
||||
const profile = await mx.http.authedRequest<Record<string, unknown>>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}`,
|
||||
);
|
||||
const v = profile[DECORATION_FIELD];
|
||||
decoration = typeof v === 'string' && v ? v : null;
|
||||
} catch {
|
||||
/* no decoration */
|
||||
}
|
||||
return {
|
||||
accountData,
|
||||
decoration,
|
||||
localSettings: settings as unknown as Record<string, unknown>,
|
||||
};
|
||||
}, [mx, userId, settings]);
|
||||
|
||||
const exportData = async () => {
|
||||
setBusy(true);
|
||||
setMessage(undefined);
|
||||
try {
|
||||
const cur = await readCurrent();
|
||||
const file = buildExport({ userId, ...cur });
|
||||
const blob = new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `lotus-chat-data-${userId.replace(/[^a-z0-9]+/gi, '-')}-${file.exportedAt.slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setMessage({ tone: 'ok', text: 'Exported. Keep the file somewhere safe.' });
|
||||
} catch {
|
||||
setMessage({ tone: 'error', text: "Couldn't export your data. Please try again." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseFile = async (f: File | undefined) => {
|
||||
if (!f) return;
|
||||
setMessage(undefined);
|
||||
const parsed = parseExport(await f.text());
|
||||
if (!parsed.ok) {
|
||||
setMessage({ tone: 'error', text: parsed.error });
|
||||
return;
|
||||
}
|
||||
const plan = planImport(parsed.file, await readCurrent());
|
||||
setPreferFile(false);
|
||||
setPending({ file: parsed.file, plan });
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const cur = await readCurrent();
|
||||
const { file } = pending;
|
||||
await Object.entries(file.accountData).reduce<Promise<void>>(
|
||||
async (prev, [type, incoming]) => {
|
||||
await prev;
|
||||
const r = mergeData(cur.accountData[type], incoming, preferFile);
|
||||
if (r.added > 0 || (preferFile && r.conflicts > 0)) {
|
||||
await mx.setAccountData(type as never, r.value as never);
|
||||
}
|
||||
},
|
||||
Promise.resolve(),
|
||||
);
|
||||
const deco = file.profile.decoration;
|
||||
if (deco && deco !== cur.decoration && (!cur.decoration || preferFile)) {
|
||||
await mx.http.authedRequest(
|
||||
Method.Put,
|
||||
`/profile/${encodeURIComponent(userId)}/${DECORATION_FIELD}`,
|
||||
undefined,
|
||||
{ [DECORATION_FIELD]: deco },
|
||||
);
|
||||
invalidateDecorationCache(userId);
|
||||
}
|
||||
if (file.local.settings) {
|
||||
const r = mergeData(cur.localSettings ?? {}, file.local.settings, preferFile);
|
||||
setSettings({ ...settings, ...(r.value as Partial<Settings>) });
|
||||
}
|
||||
setPending(undefined);
|
||||
setMessage({ tone: 'ok', text: 'Imported.' });
|
||||
} catch {
|
||||
setMessage({ tone: 'error', text: "Couldn't finish the import. Nothing else was changed." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totals = pending?.plan.reduce(
|
||||
(t, p) => ({ added: t.added + p.added, conflicts: t.conflicts + p.conflicts }),
|
||||
{ added: 0, conflicts: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Your data</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Export your data"
|
||||
description="Settings, bookmarks, private notes, room sections and names, reminders, status presets, soundboard and emoji, as one file. Not your messages or encryption keys (they have their own exports)."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={busy}
|
||||
onClick={exportData}
|
||||
>
|
||||
<Text size="B300">Export</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Import data"
|
||||
description="Load a Lotus Chat data file into this account. New items are added; you choose what happens to things that differ."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={busy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<Text size="B300">Import…</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
aria-label="Lotus Chat data file"
|
||||
onChange={(e) => {
|
||||
chooseFile(e.target.files?.[0]).catch(() => undefined);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{pending && totals && (
|
||||
<Box direction="Column" gap="200" role="region" aria-label="Import summary">
|
||||
{pending.file.userId && pending.file.userId !== userId && (
|
||||
<Text size="T200">
|
||||
This file is from <b>{pending.file.userId}</b>.
|
||||
</Text>
|
||||
)}
|
||||
{pending.plan.map((p) => (
|
||||
<Text key={p.key} size="T200">
|
||||
{p.label}:{' '}
|
||||
{p.added === 0 && p.conflicts === 0
|
||||
? 'already up to date'
|
||||
: [p.added > 0 && `${p.added} new`, p.conflicts > 0 && `${p.conflicts} differ`]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</Text>
|
||||
))}
|
||||
{totals.conflicts > 0 && (
|
||||
<Box direction="Column" gap="100" role="radiogroup" aria-label="Things that differ">
|
||||
<Text size="T200">
|
||||
<b>{totals.conflicts}</b>{' '}
|
||||
{totals.conflicts === 1 ? 'thing differs' : 'things differ'} from what's
|
||||
here:
|
||||
</Text>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="your-data-conflicts"
|
||||
checked={!preferFile}
|
||||
onChange={() => setPreferFile(false)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Keep mine
|
||||
</Text>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="your-data-conflicts"
|
||||
checked={preferFile}
|
||||
onChange={() => setPreferFile(true)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Use the file's
|
||||
</Text>
|
||||
</label>
|
||||
</Box>
|
||||
)}
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
radii="300"
|
||||
disabled={busy || (totals.added === 0 && !(preferFile && totals.conflicts > 0))}
|
||||
onClick={applyImport}
|
||||
>
|
||||
<Text size="B300">Import</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={() => setPending(undefined)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{message && (
|
||||
<Text
|
||||
size="T200"
|
||||
role="status"
|
||||
style={{ color: message.tone === 'error' ? color.Critical.Main : undefined }}
|
||||
>
|
||||
{message.text}
|
||||
</Text>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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="Add a keyword"
|
||||
title="Select Keyword"
|
||||
description="Set a notification preference for message containing given keyword."
|
||||
>
|
||||
<KeywordInput />
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
@@ -80,7 +81,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) => (
|
||||
@@ -149,6 +150,17 @@ 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,10 @@ 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>
|
||||
<RuleSection title="Override Rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
||||
<RuleSection
|
||||
title="Room rules"
|
||||
title="Room Rules"
|
||||
kind={PushRuleKind.RoomSpecific}
|
||||
rules={roomRules}
|
||||
addForm={
|
||||
@@ -348,7 +348,7 @@ export function PushRuleEditor() {
|
||||
}
|
||||
/>
|
||||
<RuleSection
|
||||
title="Sender rules"
|
||||
title="Sender Rules"
|
||||
kind={PushRuleKind.SenderSpecific}
|
||||
rules={senderRules}
|
||||
addForm={
|
||||
@@ -359,7 +359,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 notifications"
|
||||
title="Email Notification"
|
||||
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 messages arrive.</span>
|
||||
<span>Show desktop notifications when message arrive.</span>
|
||||
)
|
||||
}
|
||||
after={
|
||||
@@ -263,8 +263,8 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Notification sound"
|
||||
description="Play a sound when a new message arrives."
|
||||
title="Notification Sound"
|
||||
description="Play sound when new message arrive."
|
||||
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} />}
|
||||
/>
|
||||
|
||||
@@ -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 → General → 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 → General → App Updates.`,
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
sticky: true,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
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[]>([]);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildExport, mergeData, parseExport, planImport } from './dataExport';
|
||||
|
||||
const file = buildExport({
|
||||
userId: '@a:hs',
|
||||
accountData: {
|
||||
'io.lotus.bookmarks': { bookmarks: [{ id: 'b1', note: 'x' }] },
|
||||
'io.lotus.user_notes': { notes: { '@b:hs': 'met at LAN party' } },
|
||||
'm.direct': { secret: true },
|
||||
'io.lotus.settings': { theme: 'dark', fontSize: 16 },
|
||||
},
|
||||
decoration: 'halo',
|
||||
localSettings: { callNoiseSuppression: 'ml' },
|
||||
now: new Date('2026-10-25T00:00:00Z'),
|
||||
});
|
||||
|
||||
test('[#114] export keeps only our own types', () => {
|
||||
assert.equal(file.format, 'lotus-chat-data');
|
||||
assert.equal(file.version, 1);
|
||||
assert.deepEqual(Object.keys(file.accountData).sort(), [
|
||||
'io.lotus.bookmarks',
|
||||
'io.lotus.settings',
|
||||
'io.lotus.user_notes',
|
||||
]);
|
||||
assert.equal(file.profile.decoration, 'halo');
|
||||
});
|
||||
|
||||
test('[#114] parse: round trip, wrong files, newer versions, foreign types', () => {
|
||||
const r = parseExport(JSON.stringify(file));
|
||||
assert.ok(r.ok);
|
||||
assert.equal(parseExport('nope').ok, false);
|
||||
assert.equal(parseExport('{"format":"other"}').ok, false);
|
||||
const newer = parseExport(JSON.stringify({ ...file, version: 2 }));
|
||||
assert.ok(!newer.ok && /newer Lotus Chat/.test(newer.error));
|
||||
const sneaky = parseExport(
|
||||
JSON.stringify({ ...file, accountData: { 'm.push_rules': {}, 'io.lotus.bookmarks': {} } }),
|
||||
);
|
||||
assert.ok(sneaky.ok);
|
||||
assert.deepEqual(Object.keys(sneaky.file.accountData), ['io.lotus.bookmarks']);
|
||||
});
|
||||
|
||||
test('[#114] merge: lists by id, maps by key, conflicts kept unless preferred', () => {
|
||||
const mine = { bookmarks: [{ id: 'b1', note: 'mine' }, { id: 'b2' }] };
|
||||
const theirs = { bookmarks: [{ id: 'b1', note: 'theirs' }, { id: 'b3' }] };
|
||||
const keep = mergeData(mine, theirs);
|
||||
assert.equal(keep.added, 1);
|
||||
assert.equal(keep.conflicts, 1);
|
||||
assert.deepEqual(keep.value, {
|
||||
bookmarks: [{ id: 'b1', note: 'mine' }, { id: 'b2' }, { id: 'b3' }],
|
||||
});
|
||||
const take = mergeData(mine, theirs, true);
|
||||
assert.deepEqual((take.value as typeof mine).bookmarks[0], { id: 'b1', note: 'theirs' });
|
||||
|
||||
const notes = mergeData({ a: 1, b: 2 }, { b: 3, c: 4 });
|
||||
assert.deepEqual(notes, { value: { a: 1, b: 2, c: 4 }, added: 1, conflicts: 1 });
|
||||
assert.deepEqual(mergeData(undefined, { x: 1 }), { value: { x: 1 }, added: 1, conflicts: 0 });
|
||||
assert.deepEqual(mergeData(['a'], ['a', 'b']).value, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('[#114] plan summarises per category', () => {
|
||||
const plan = planImport(file, {
|
||||
accountData: { 'io.lotus.settings': { theme: 'light' } },
|
||||
decoration: null,
|
||||
localSettings: { callNoiseSuppression: 'ml' },
|
||||
});
|
||||
const by = Object.fromEntries(plan.map((p) => [p.key, p]));
|
||||
assert.deepEqual(
|
||||
[by['io.lotus.settings'].added, by['io.lotus.settings'].conflicts],
|
||||
[1, 1],
|
||||
'fontSize new, theme differs',
|
||||
);
|
||||
assert.equal(by['io.lotus.bookmarks'].added, 1);
|
||||
assert.equal(by['profile.decoration'].added, 1);
|
||||
assert.deepEqual([by['local.settings'].added, by['local.settings'].conflicts], [0, 0]);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* [Gitea #114] Export / import "your data": the Lotus-side personal data of
|
||||
* an account (not message history, not encryption keys: those have their own
|
||||
* exports, and keys are secret). One JSON file:
|
||||
*
|
||||
* { format: 'lotus-chat-data', version: 1, exportedAt, userId,
|
||||
* accountData: { [type]: content }, profile: { decoration }, local: { settings } }
|
||||
*
|
||||
* Import MERGES: anything missing here is added; a value that differs is a
|
||||
* conflict, kept as it is unless the user chooses the file's. Pure helpers.
|
||||
*/
|
||||
|
||||
export const EXPORT_FORMAT = 'lotus-chat-data';
|
||||
export const EXPORT_VERSION = 1;
|
||||
|
||||
/** The account-data types that are the user's own Lotus data, with labels. */
|
||||
export const EXPORT_ACCOUNT_DATA: { type: string; label: string }[] = [
|
||||
{ type: 'io.lotus.settings', label: 'Synced settings' },
|
||||
{ type: 'io.lotus.bookmarks', label: 'Bookmarks' },
|
||||
{ type: 'io.lotus.user_notes', label: 'Private notes about people' },
|
||||
{ type: 'io.lotus.room_names', label: 'Your names for rooms' },
|
||||
{ type: 'in.cinny.spaces', label: 'Room sections and sidebar' },
|
||||
{ type: 'io.lotus.reminders', label: 'Reminders' },
|
||||
{ type: 'io.lotus.status_presets', label: 'Status presets' },
|
||||
{ type: 'io.lotus.thread_notifications', label: 'Thread notification settings' },
|
||||
{ type: 'io.lotus.mute_timers', label: 'Timed mutes' },
|
||||
{ type: 'io.lotus.soundboard', label: 'Soundboard (clips stay on the server)' },
|
||||
{ type: 'io.lotus.soundboard_rooms', label: 'Soundboards enabled everywhere' },
|
||||
{ type: 'im.ponies.user_emotes', label: 'Your emoji and stickers' },
|
||||
{ type: 'im.ponies.emote_rooms', label: 'Emoji packs enabled everywhere' },
|
||||
];
|
||||
|
||||
export type ExportFile = {
|
||||
format: typeof EXPORT_FORMAT;
|
||||
version: number;
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
accountData: Record<string, unknown>;
|
||||
profile: { decoration: string | null };
|
||||
local: { settings: Record<string, unknown> | null };
|
||||
};
|
||||
|
||||
export const buildExport = (input: {
|
||||
userId: string;
|
||||
accountData: Record<string, unknown>;
|
||||
decoration: string | null;
|
||||
localSettings: Record<string, unknown> | null;
|
||||
now?: Date;
|
||||
}): ExportFile => {
|
||||
const accountData: Record<string, unknown> = {};
|
||||
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
|
||||
const c = input.accountData[type];
|
||||
if (c !== undefined && c !== null) accountData[type] = c;
|
||||
});
|
||||
return {
|
||||
format: EXPORT_FORMAT,
|
||||
version: EXPORT_VERSION,
|
||||
exportedAt: (input.now ?? new Date()).toISOString(),
|
||||
userId: input.userId,
|
||||
accountData,
|
||||
profile: { decoration: input.decoration },
|
||||
local: { settings: input.localSettings },
|
||||
};
|
||||
};
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
!!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
export type ParseResult = { ok: true; file: ExportFile } | { ok: false; error: string };
|
||||
|
||||
/** Validate an imported file; older versions would be migrated here. */
|
||||
export const parseExport = (text: string): ParseResult => {
|
||||
let v: unknown;
|
||||
try {
|
||||
v = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, error: "This isn't a Lotus Chat data file (it isn't valid JSON)." };
|
||||
}
|
||||
if (!isObject(v) || v.format !== EXPORT_FORMAT)
|
||||
return { ok: false, error: "This isn't a Lotus Chat data file." };
|
||||
if (typeof v.version !== 'number' || v.version < 1)
|
||||
return { ok: false, error: 'This data file is damaged (no version).' };
|
||||
if (v.version > EXPORT_VERSION)
|
||||
return {
|
||||
ok: false,
|
||||
error: 'This file was made by a newer Lotus Chat. Update the app, then import it again.',
|
||||
};
|
||||
const accountData = isObject(v.accountData) ? v.accountData : {};
|
||||
const known = new Set(EXPORT_ACCOUNT_DATA.map((t) => t.type));
|
||||
const filtered: Record<string, unknown> = {};
|
||||
Object.entries(accountData).forEach(([type, content]) => {
|
||||
// Only our own types: a file can't be used to write arbitrary account data.
|
||||
if (known.has(type) && content !== null && content !== undefined) filtered[type] = content;
|
||||
});
|
||||
const profile = isObject(v.profile) ? v.profile : {};
|
||||
const local = isObject(v.local) ? v.local : {};
|
||||
return {
|
||||
ok: true,
|
||||
file: {
|
||||
format: EXPORT_FORMAT,
|
||||
version: v.version,
|
||||
exportedAt: typeof v.exportedAt === 'string' ? v.exportedAt : '',
|
||||
userId: typeof v.userId === 'string' ? v.userId : '',
|
||||
accountData: filtered,
|
||||
profile: {
|
||||
decoration: typeof profile.decoration === 'string' ? profile.decoration : null,
|
||||
},
|
||||
local: { settings: isObject(local.settings) ? local.settings : null },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
const itemKey = (v: unknown): string => {
|
||||
if (isObject(v)) {
|
||||
const id = v.id ?? v.eventId ?? v.event_id ?? v.key ?? v.roomId ?? v.room_id ?? v.mxc;
|
||||
if (typeof id === 'string' || typeof id === 'number') return `id:${String(id)}`;
|
||||
}
|
||||
return `json:${JSON.stringify(v)}`;
|
||||
};
|
||||
|
||||
export type MergeResult = { value: unknown; added: number; conflicts: number };
|
||||
|
||||
/**
|
||||
* Pure: merge `incoming` into `current`. Lists: add items that are missing
|
||||
* (matched by id, else by content). Objects: add missing keys, recurse into
|
||||
* shared ones. A differing leaf is a conflict: `preferIncoming` decides.
|
||||
*/
|
||||
export const mergeData = (
|
||||
current: unknown,
|
||||
incoming: unknown,
|
||||
preferIncoming = false,
|
||||
): MergeResult => {
|
||||
if (current === undefined || current === null) {
|
||||
return { value: incoming, added: incoming === undefined ? 0 : 1, conflicts: 0 };
|
||||
}
|
||||
if (Array.isArray(current) && Array.isArray(incoming)) {
|
||||
const have = new Map(current.map((v, i) => [itemKey(v), i]));
|
||||
const out = [...current];
|
||||
let added = 0;
|
||||
let conflicts = 0;
|
||||
incoming.forEach((item) => {
|
||||
const k = itemKey(item);
|
||||
const at = have.get(k);
|
||||
if (at === undefined) {
|
||||
out.push(item);
|
||||
have.set(k, out.length - 1);
|
||||
added += 1;
|
||||
} else if (!same(out[at], item)) {
|
||||
conflicts += 1;
|
||||
if (preferIncoming) out[at] = item;
|
||||
}
|
||||
});
|
||||
return { value: out, added, conflicts };
|
||||
}
|
||||
if (isObject(current) && isObject(incoming)) {
|
||||
const out: Record<string, unknown> = { ...current };
|
||||
let added = 0;
|
||||
let conflicts = 0;
|
||||
Object.entries(incoming).forEach(([k, v]) => {
|
||||
if (!(k in current)) {
|
||||
out[k] = v;
|
||||
added += 1;
|
||||
return;
|
||||
}
|
||||
const r = mergeData(current[k], v, preferIncoming);
|
||||
out[k] = r.value;
|
||||
added += r.added;
|
||||
conflicts += r.conflicts;
|
||||
});
|
||||
return { value: out, added, conflicts };
|
||||
}
|
||||
if (same(current, incoming)) return { value: current, added: 0, conflicts: 0 };
|
||||
return { value: preferIncoming ? incoming : current, added: 0, conflicts: 1 };
|
||||
};
|
||||
|
||||
export type ImportPlanItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
added: number;
|
||||
conflicts: number;
|
||||
};
|
||||
|
||||
/** What importing `file` into the current data would change, per category. */
|
||||
export const planImport = (
|
||||
file: ExportFile,
|
||||
current: {
|
||||
accountData: Record<string, unknown>;
|
||||
decoration: string | null;
|
||||
localSettings: Record<string, unknown> | null;
|
||||
},
|
||||
): ImportPlanItem[] => {
|
||||
const items: ImportPlanItem[] = [];
|
||||
EXPORT_ACCOUNT_DATA.forEach(({ type, label }) => {
|
||||
if (!(type in file.accountData)) return;
|
||||
const r = mergeData(current.accountData[type], file.accountData[type]);
|
||||
items.push({ key: type, label, added: r.added, conflicts: r.conflicts });
|
||||
});
|
||||
if (file.profile.decoration !== null) {
|
||||
const differs = current.decoration !== file.profile.decoration;
|
||||
items.push({
|
||||
key: 'profile.decoration',
|
||||
label: 'Avatar decoration',
|
||||
added: !current.decoration && differs ? 1 : 0,
|
||||
conflicts: current.decoration && differs ? 1 : 0,
|
||||
});
|
||||
}
|
||||
if (file.local.settings) {
|
||||
const r = mergeData(current.localSettings ?? {}, file.local.settings);
|
||||
items.push({
|
||||
key: 'local.settings',
|
||||
label: "This device's settings",
|
||||
added: r.added,
|
||||
conflicts: r.conflicts,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
parseNudgeRecord,
|
||||
SecurityState,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from './securityNudge';
|
||||
|
||||
const healthy: SecurityState = {
|
||||
@@ -82,45 +79,3 @@ 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,15 +9,12 @@
|
||||
* - 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 → Devices 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' | 'stale-sessions';
|
||||
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup';
|
||||
|
||||
export type SecurityState = {
|
||||
/** m.cross_signing.master account data present. */
|
||||
@@ -41,51 +38,6 @@ 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;
|
||||
@@ -135,11 +87,6 @@ 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