Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 02bd356a89 fix(media): retry an image once when it raced the service worker
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m44s
On a first visit (or right after an update) the service worker only takes
control when it activates. An image requested before then goes out without
the access token, fails, and showed "Retry". Now a failed image waits for the
worker to take control (or a moment) and retries once by itself; "Retry"
appears only if that fails too. Encrypted media is unchanged.

Also the cause of the flaky "#219 gallery lightbox" e2e test (1 in 4 locally,
10/10 after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 19:11:56 -04:00
Lotus CIandClaude Opus 5.5 22d5aea5e6 feat(settings): search across all settings (#463)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m44s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
CI / Playwright smoke (e2e) (pull_request) Failing after 23m12s
A search box at the top of the settings sidebar (Ctrl/Cmd+F while settings
are open). Results replace the page list while typing, showing the page and
section; picking one (or Enter for the top result) opens that page, scrolls
to the setting and briefly outlines it. Esc clears.

The index is built from the settings source itself (SettingTile titles and
descriptions, section headers), loaded as raw text in a lazy chunk on first
search, so options added later are found without a hand-kept list.
Desktop-only options only show in the desktop app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 12:36:21 -04:00
Lotus CIandClaude Opus 5.5 b93e4bcef9 fix(settings): visible radio buttons for the privacy and import choices
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 19m52s
The app CSS hides native radio inputs, so the read-receipt choice and the
"keep mine / use the file's" import choice showed no selection. Use the
folds RadioButton.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 12:16:14 -04:00
Lotus CIandClaude Opus 5.5 1af491cabb feat(settings): privacy choice and link previews; grouped call settings (#461, #462)
#461 Both link-preview switches move into Privacy. The two overlapping
receipt switches become one choice that maps exactly onto the existing
settings (no migration, every old combination behaves the same):
Everyone / Private read receipts / Hide my activity (also hides typing
and other people's receipts, as "Hide typing and read receipts" did).

#462 Calls & audio is grouped, the same in every mode: Joining,
Microphone and noise, Push to talk and hotkeys, Sounds, Quality,
Soundboard. No setting changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 11:56:24 -04:00
Lotus CIandClaude Opus 5.5 449d0c740e feat(settings): split General into pages; collapse the big pickers (#459, #460)
#459 General held 76 of ~110 settings (11 screens). Its sections now live
on pages named for what people come to do:
  Account, Appearance, Messages (incl. editor, date and time),
  Notifications, Calls & audio, Privacy & security (privacy options +
  the former Devices page), Data & storage (storage, sync, your data),
  Desktop (desktop app only), Emojis & stickers, Advanced (the former
  Developer Tools + "show hidden events" / "legacy username colour"),
  About.
The longest page is now ~3 screens (Appearance). Settings opens on
Account. Deep links keep working: "devices" lands on Privacy & security,
the old "general" on Appearance, the update notice on Desktop. Text that
pointed at the old page names is updated. The push-rule editor gets one
line explaining the Matrix terms. Remaining Title Case labels on Account
and Devices are sentence case.

#460 Chat background, seasonal theme and avatar decoration grids sit
behind one row: the current choice and a Change button (the first two
close after a choice; decorations keep their Save step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 11:56:24 -04:00
jared 299da879fb Merge pull request 'feat(settings): quick wins from the settings audit (#453 phase 1)' (#464) from feat/settings-quick-wins into lotus
CI / Build & Quality Checks (push) Successful in 4m16s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 22s
CI / Trigger Desktop Build (push) Successful in 14s
CI / Playwright smoke (e2e) (push) Successful in 21m43s
2026-10-25 02:48:55 -04:00
jared f8fa1ca5ed Merge pull request 'feat(settings): export / import your data (#114)' (#454) from feat/data-export into lotus
CI / Build & Quality Checks (push) Successful in 3m50s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 17s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-25 02:16:03 -04:00
Lotus CIandClaude Opus 5.5 378b40fabe feat(settings): export / import your data (#114)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m38s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 22m30s
Settings -> General -> "Your data": Export writes one JSON file of the
account's Lotus data (synced settings, bookmarks, private notes, room names
and sections, reminders, status presets, thread notification settings,
timed mutes, soundboard (clips stay on the server), emoji packs, avatar
decoration, and this device's settings). Not messages or encryption keys:
they have their own exports, and keys are secret.

Import merges: new items are added; things that differ are listed per
category and kept unless the user picks "Use the file's". Only our own
account-data types are read from a file. Files from a newer Lotus Chat
are refused with a plain message; the version stamp leaves room to
migrate older ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 01:08:28 -04:00
33 changed files with 2255 additions and 760 deletions
@@ -1,22 +1,31 @@
- navigation "Settings sections":
- button "General" [pressed]:
- img
- paragraph: General
- button "Account":
- button "Account" [pressed]:
- img
- paragraph: Account
- button "Appearance":
- img
- paragraph: Appearance
- button "Messages":
- img
- paragraph: Messages
- button "Notifications":
- img
- paragraph: Notifications
- button "Devices":
- button "Calls & audio":
- img
- paragraph: Devices
- button "Emojis & Stickers":
- paragraph: Calls & audio
- button "Privacy & security":
- img
- paragraph: Emojis & Stickers
- button "Developer Tools":
- paragraph: Privacy & security
- button "Data & storage":
- img
- paragraph: Developer Tools
- paragraph: Data & storage
- button "Emojis & stickers":
- img
- paragraph: Emojis & stickers
- button "Advanced":
- img
- paragraph: Advanced
- button "About":
- img
- paragraph: About
+1 -1
View File
@@ -31,7 +31,7 @@ test.describe('key upload recovery (#201)', () => {
expect(rejected).toBeGreaterThanOrEqual(3);
await banner.getByRole('button', { name: 'Details' }).click();
await expect(banner).toContainText('Settings → Devices');
await expect(banner).toContainText('Settings → Privacy & security');
await expect(banner).toContainText('already exists');
await banner.getByRole('button', { name: /Fix: sign out/ }).click();
+3 -1
View File
@@ -75,7 +75,9 @@ test.describe('security nudge (#110, #123)', () => {
await expect(strip(page)).toHaveCount(0);
});
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
test('no key backup: "Set up key backup" opens Settings → Privacy & security', async ({
page,
}) => {
test.setTimeout(150_000);
await loginUI(page, user);
await pastGrace(page);
+51
View File
@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
// [Gitea #463] Search across all settings.
test.describe('settings search (#463)', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('finds a setting, opens its page and shows it', async ({ page }) => {
test.setTimeout(120_000);
const user = await ensureUser(uniq('ss'));
await loginUI(page, user);
await page.getByRole('button', { name: 'User Settings' }).click();
const dialog = page.getByRole('dialog').first();
const box = dialog.getByRole('textbox', { name: 'Search settings' });
// Ctrl+F focuses the box while settings are open (after the dialog has
// placed its own initial focus).
await expect(box).toBeVisible();
await page.waitForTimeout(800);
await page.keyboard.press('ControlOrMeta+f');
await expect(box).toBeFocused();
await box.fill('deafen');
const results = dialog.getByRole('list', { name: 'Matching settings' });
const first = results.getByRole('listitem').first();
await expect(first).toContainText('Push to deafen');
await expect(first).toContainText('Calls & audio');
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeHidden();
await first.click();
await expect(dialog.getByText('Calls & audio', { exact: true }).last()).toBeVisible();
await expect(dialog.getByText('Push to deafen', { exact: true })).toBeInViewport();
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeVisible();
// A description word finds it too; Enter picks the top result.
await box.fill('utm');
await expect(results.getByRole('listitem').first()).toContainText(
'Strip tracking parameters from links',
);
await box.press('Enter');
await expect(dialog.getByText('Strip tracking parameters from links')).toBeInViewport();
// No match, then Esc clears.
await box.fill('zzzz-nothing');
await expect(dialog.getByText(/No settings match/)).toBeVisible();
await box.press('Escape');
await expect(box).toHaveValue('');
await expect(dialog).toBeVisible();
});
});
+98
View File
@@ -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('Data & storage', { 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();
});
});
+2 -2
View File
@@ -19,7 +19,7 @@ import { useModalStyle } from '../hooks/useModalStyle';
* cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat
* should keep running in the tray (calls, messages, notifications) or quit —
* and, in the same moment, whether it should start when you sign in. Asked
* once; changeable in Settings → General. The native side sends
* once; changeable in Settings → Desktop. The native side sends
* `lotus-close-requested` only while the choice is still "ask", and never
* during a call (a call always goes to the tray).
*/
@@ -149,7 +149,7 @@ export function CloseBehaviorPrompt() {
)}
</Box>
<Text size="T200" priority="300">
You can change this in Settings → General.
You can change this in Settings → Desktop.
</Text>
</Box>
</Dialog>
@@ -33,6 +33,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { useNearViewport } from '../../../hooks/useNearViewport';
import { whenMediaAuthReady } from '../../../../sw-session';
type RenderViewerProps = {
src: string;
@@ -115,13 +116,24 @@ export const ImageContent = as<'div', ImageContentProps>(
const handleLoad = () => {
setLoad(true);
};
// An image requested before the service worker controls the page goes out
// without the access token and fails (first visit, just after an update).
// Retry once by itself when that's ready; only then offer "Retry".
const [attempt, setAttempt] = useState(0);
const autoRetried = useRef(false);
const handleError = () => {
setLoad(false);
if (!autoRetried.current && !encInfo) {
autoRetried.current = true;
whenMediaAuthReady().then(() => setAttempt((n) => n + 1));
return;
}
setError(true);
};
const handleRetry = () => {
setError(false);
setAttempt((n) => n + 1);
loadSrc();
};
@@ -182,7 +194,7 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box>
)}
{srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
<Box key={attempt} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderImage({
alt: body,
title: body,
@@ -48,7 +48,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
>
<SettingTile
title="Message Retention"
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → Messages; full server-side deletion also requires homeserver retention to be configured."
>
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
{RETENTION_PRESETS.map((preset) => {
+105 -45
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Avatar,
Box,
@@ -13,9 +13,19 @@ import {
OverlayBackdrop,
OverlayCenter,
Text,
color,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { General } from './general';
import {
AdvancedDisplayOptions,
AppearancePage,
CallsPage,
DataStoragePage,
DesktopPage,
MessagesPage,
Privacy,
} from './general';
import { isTauri as isTauriEnv } from '../../hooks/useTauri';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { Account } from './account';
@@ -34,6 +44,8 @@ import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
import { SettingsPages } from './pages';
import { SettingsSearch } from './search/SettingsSearch';
import type { SettingsEntry } from './search/settingsIndex';
export { SettingsPages };
@@ -45,43 +57,23 @@ type SettingsMenuItem = {
const useSettingsMenuItems = (): SettingsMenuItem[] =>
useMemo(
() => [
{
page: SettingsPages.GeneralPage,
name: 'General',
icon: Icons.Setting,
},
{
page: SettingsPages.AccountPage,
name: 'Account',
icon: Icons.User,
},
{
page: SettingsPages.NotificationPage,
name: 'Notifications',
icon: Icons.Bell,
},
{
page: SettingsPages.DevicesPage,
name: 'Devices',
icon: Icons.Monitor,
},
{
page: SettingsPages.EmojisStickersPage,
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: SettingsPages.DeveloperToolsPage,
name: 'Developer Tools',
icon: Icons.Terminal,
},
{
page: SettingsPages.AboutPage,
name: 'About',
icon: Icons.Info,
},
],
() =>
[
{ page: SettingsPages.AccountPage, name: 'Account', icon: Icons.User },
{ page: SettingsPages.AppearancePage, name: 'Appearance', icon: Icons.Sun },
{ page: SettingsPages.MessagesPage, name: 'Messages', icon: Icons.Message },
{ page: SettingsPages.NotificationPage, name: 'Notifications', icon: Icons.Bell },
{ page: SettingsPages.CallsPage, name: 'Calls & audio', icon: Icons.Phone },
{ page: SettingsPages.DevicesPage, name: 'Privacy & security', icon: Icons.ShieldUser },
{ page: SettingsPages.DataStoragePage, name: 'Data & storage', icon: Icons.Download },
// [Gitea #459] Desktop-only options: nothing to show in a browser.
...(isTauriEnv()
? [{ page: SettingsPages.DesktopPage, name: 'Desktop', icon: Icons.Monitor }]
: []),
{ page: SettingsPages.EmojisStickersPage, name: 'Emojis & stickers', icon: Icons.Smile },
{ page: SettingsPages.DeveloperToolsPage, name: 'Advanced', icon: Icons.Terminal },
{ page: SettingsPages.AboutPage, name: 'About', icon: Icons.Info },
] as SettingsMenuItem[],
[],
);
@@ -102,9 +94,52 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
const screenSize = useScreenSizeContext();
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
});
const menuItems = useSettingsMenuItems();
// [Gitea #463] Settings search: results replace the page list while typing;
// picking one opens its page and briefly highlights the setting.
const pageNames = useMemo(
() => new Map(menuItems.map((item) => [item.page, item.name])),
[menuItems],
);
const [searching, setSearching] = useState(false);
const [highlight, setHighlight] = useState<string>();
const pickResult = useCallback((entry: SettingsEntry) => {
setActivePage(entry.page);
setHighlight(entry.title);
}, []);
useEffect(() => {
if (!highlight) return undefined;
let tries = 0;
let flashTimer: number | undefined;
const find = () => {
const match = [...document.querySelectorAll<HTMLElement>('p, span, label, h1, h2, h3')].find(
(el) =>
!el.closest('nav') && el.textContent?.trim() === highlight && el.offsetParent !== null,
);
if (!match) {
tries += 1;
if (tries < 20) poll = window.setTimeout(find, 100);
else setHighlight(undefined);
return;
}
const target = (match.closest('[class*="SequenceCard"]') as HTMLElement | null) ?? match;
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
const prev = target.style.boxShadow;
target.style.boxShadow = `0 0 0 2px ${color.Primary.Main}`;
target.style.transition = 'box-shadow 0.3s';
flashTimer = window.setTimeout(() => {
target.style.boxShadow = prev;
setHighlight(undefined);
}, 1800);
};
let poll = window.setTimeout(find, 50);
return () => {
window.clearTimeout(poll);
window.clearTimeout(flashTimer);
};
}, [highlight, activePage]);
const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -145,8 +180,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Box>
</PageNavHeader>
<Box grow="Yes" direction="Column">
<SettingsSearch
pageNames={pageNames}
onActiveChange={setSearching}
onPick={pickResult}
/>
<PageNavContent>
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
<nav aria-label="Settings sections" style={{ flexGrow: 1 }} hidden={searching}>
{menuItems.map((item) => (
<MenuItem
key={item.name}
@@ -207,8 +247,20 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
)
}
>
{activePage === SettingsPages.GeneralPage && (
<General requestClose={handlePageRequestClose} />
{activePage === SettingsPages.AppearancePage && (
<AppearancePage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.MessagesPage && (
<MessagesPage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.CallsPage && (
<CallsPage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DataStoragePage && (
<DataStoragePage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DesktopPage && (
<DesktopPage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AccountPage && (
<Account requestClose={handlePageRequestClose} />
@@ -217,13 +269,21 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
<Devices
title="Privacy & security"
before={<Privacy />}
requestClose={handlePageRequestClose}
/>
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
<DeveloperTools
title="Advanced"
before={<AdvancedDisplayOptions />}
requestClose={handlePageRequestClose}
/>
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot>
@@ -313,7 +313,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
<SettingTile
title={
<Text as="span" size="L400">
Display Name
Display name
</Text>
}
>
@@ -576,7 +576,7 @@ function ProfileStatus() {
<SettingTile
title={
<Text as="span" size="L400">
Status Message
Status message
</Text>
}
description={
@@ -102,6 +102,8 @@ export function ProfileDecoration() {
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
// [Gitea #460] The catalog stays closed until "Change"; it was always open.
const [browsing, setBrowsing] = useState(false);
// Distinguish "confirmed no decoration" from "failed to load": a fetch
// failure must not be shown as (and saved over) "None".
const [loadError, setLoadError] = useState(false);
@@ -199,7 +201,7 @@ export function ProfileDecoration() {
<SettingTile
title={
<Text as="span" size="L400">
Avatar Decoration
Avatar decoration
</Text>
}
description={
@@ -282,6 +284,20 @@ export function ProfileDecoration() {
</Button>
)}
</Box>
{!loadError && (
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
outlined
aria-expanded={browsing}
onClick={() => setBrowsing((b) => !b)}
>
<Text size="B300">{browsing ? 'Done' : 'Change'}</Text>
</Button>
)}
{hasChanges && !loadError && (
<Button
type="button"
@@ -324,73 +340,77 @@ export function ProfileDecoration() {
</Text>
)}
{/* Search + category tabs. Only one category (or the search results)
{browsing && (
<>
{/* Search + category tabs. Only one category (or the search results)
is mounted at a time so the picker never loads the whole catalog. */}
<Input
aria-label="Search decorations"
placeholder="Search decorations"
value={search}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
variant="Secondary"
size="400"
radii="300"
before={<Icon size="100" src={Icons.Search} />}
/>
{!query && (
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
{DECORATION_CATEGORIES.map((category) => (
<Chip
key={category.id}
type="button"
role="tab"
aria-selected={category.id === activeCategory}
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
fill="Soft"
outlined={category.id === activeCategory}
radii="Pill"
onClick={() => setActiveCategory(category.id)}
>
<Text as="span" size="B300">
{category.label}
</Text>
</Chip>
))}
</Box>
)}
<div
role={query ? undefined : 'tabpanel'}
style={{
maxHeight: 360,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
}}
>
{visible.length === 0 ? (
<Text size="T300" priority="300">
No decorations match “{search.trim()}”.
</Text>
) : (
<Input
aria-label="Search decorations"
placeholder="Search decorations"
value={search}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
variant="Secondary"
size="400"
radii="300"
before={<Icon size="100" src={Icons.Search} />}
/>
{!query && (
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
{DECORATION_CATEGORIES.map((category) => (
<Chip
key={category.id}
type="button"
role="tab"
aria-selected={category.id === activeCategory}
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
fill="Soft"
outlined={category.id === activeCategory}
radii="Pill"
onClick={() => setActiveCategory(category.id)}
>
<Text as="span" size="B300">
{category.label}
</Text>
</Chip>
))}
</Box>
)}
<div
role={query ? undefined : 'tabpanel'}
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
padding: 2,
maxHeight: 360,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
}}
>
{visible.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
{visible.length === 0 ? (
<Text size="T300" priority="300">
No decorations match “{search.trim()}”.
</Text>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
padding: 2,
}}
>
{visible.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
)}
</div>
)}
</div>
</>
)}
</Box>
</SettingTile>
);
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useState, ReactNode } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
@@ -18,8 +18,12 @@ import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
type DeveloperToolsProps = {
requestClose: () => void;
/** [Gitea #459] Shown as the page title (the menu entry's name). */
title?: string;
/** [Gitea #459] Sections shown above this page's own. */
before?: ReactNode;
};
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
export function DeveloperTools({ requestClose, title, before }: DeveloperToolsProps) {
const mx = useMatrixClient();
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false);
@@ -49,7 +53,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Developer Tools
{title ?? 'Developer Tools'}
</Text>
</Box>
<Box shrink="No">
@@ -63,6 +67,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{before}
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
@@ -137,7 +137,7 @@ function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceR
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
<Text size="L400">Device Name</Text>
<Text size="L400">Device name</Text>
<Box gap="200">
<Box grow="Yes" direction="Column">
<Input
@@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
@@ -38,8 +38,12 @@ function DevicesPlaceholder() {
type DevicesProps = {
requestClose: () => void;
/** [Gitea #459] Shown as the page title (the menu entry's name). */
title?: string;
/** [Gitea #459] Sections shown above this page's own. */
before?: ReactNode;
};
export function Devices({ requestClose }: DevicesProps) {
export function Devices({ requestClose, title, before }: DevicesProps) {
const mx = useMatrixClient();
const crypto = mx.getCrypto();
const crossSigningActive = useCrossSigningActive();
@@ -70,7 +74,7 @@ export function Devices({ requestClose }: DevicesProps) {
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Devices
{title ?? 'Devices'}
</Text>
</Box>
<Box shrink="No">
@@ -84,6 +88,7 @@ export function Devices({ requestClose }: DevicesProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{before}
<Box direction="Column" gap="100">
<Text size="L400">Security</Text>
<SequenceCard
@@ -67,7 +67,7 @@ function ExportKeys() {
{(match, doMatch, passRef, confPassRef) => (
<>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">New Password</Text>
<Text size="L400">New password</Text>
<PasswordInput
ref={passRef}
name="passwordInput"
@@ -82,7 +82,7 @@ function ExportKeys() {
/>
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Confirm Password</Text>
<Text size="L400">Confirm password</Text>
<PasswordInput
ref={confPassRef}
style={{ color: match ? undefined : color.Critical.Main }}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
import React, { useCallback, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { Method } from 'matrix-js-sdk';
import { Box, Button, RadioButton, Text, color } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
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&apos;s
here:
</Text>
<label>
<RadioButton
name="your-data-conflicts"
variant="Primary"
size="300"
checked={!preferFile}
onClick={() => setPreferFile(false)}
/>{' '}
<Text as="span" size="T200">
Keep mine
</Text>
</label>
<label>
<RadioButton
name="your-data-conflicts"
variant="Primary"
size="300"
checked={preferFile}
onClick={() => setPreferFile(true)}
/>{' '}
<Text as="span" size="T200">
Use the file&apos;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>
);
}
@@ -334,6 +334,11 @@ export function PushRuleEditor() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Advanced push rules</Text>
{/* [Gitea #459] Matrix terms kept, with one line on what they are. */}
<Text size="T200" priority="300">
The raw Matrix notification rules behind the options above, checked in this order: override,
room, sender, then underride. You rarely need to change these.
</Text>
<RuleSection title="Override rules" kind={PushRuleKind.Override} rules={overrideRules} />
<RuleSection
title="Room rules"
+9 -1
View File
@@ -1,11 +1,19 @@
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
* page without pulling the dialog into the entry chunk. */
export enum SettingsPages {
GeneralPage,
AccountPage,
// [Gitea #459] These replace the old catch-all General page.
AppearancePage,
MessagesPage,
NotificationPage,
CallsPage,
/** "Privacy & security": privacy options plus devices and encryption. */
DevicesPage,
DataStoragePage,
/** Desktop app only. */
DesktopPage,
EmojisStickersPage,
/** "Advanced": developer tools and display plumbing. */
DeveloperToolsPage,
AboutPage,
}
@@ -0,0 +1,129 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Input, MenuItem, Text, config } from 'folds';
import { SettingsPages } from '../pages';
import { searchSettings, SettingsEntry } from './settingsIndex';
import { loadSettingsIndex } from './loadSettingsIndex';
import { isTauri } from '../../../hooks/useTauri';
type SettingsSearchProps = {
pageNames: Map<SettingsPages, string>;
/** Whether a query is active (the page list is hidden meanwhile). */
onActiveChange: (active: boolean) => void;
onPick: (entry: SettingsEntry) => void;
};
/**
* [Gitea #463] Search box at the top of the settings sidebar. Ctrl/Cmd+F
* focuses it while settings are open; Esc clears it.
*/
export function SettingsSearch({ pageNames, onActiveChange, onPick }: SettingsSearchProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [entries, setEntries] = useState<SettingsEntry[]>();
const active = query.trim().length > 0;
useEffect(() => onActiveChange(active), [active, onActiveChange]);
const ensureIndex = () => {
if (!entries) loadSettingsIndex().then(setEntries, () => setEntries([]));
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
// Capture phase + stop: settings is on top, so this beats the app's
// own Ctrl/Cmd+F (message search) underneath.
e.preventDefault();
e.stopPropagation();
inputRef.current?.focus();
inputRef.current?.select();
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, []);
// Only desktop-app options in the desktop app; only pages that exist here.
const results = entries
? searchSettings(entries, query, { desktop: isTauri() }).filter((e) => pageNames.has(e.page))
: [];
return (
<Box direction="Column" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
<Input
ref={inputRef}
size="400"
variant="Background"
radii="400"
placeholder="Search settings"
aria-label="Search settings"
value={query}
onFocus={ensureIndex}
onChange={(e) => {
ensureIndex();
setQuery(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === 'Escape' && query) {
e.stopPropagation();
setQuery('');
}
if (e.key === 'Enter' && results[0]) {
onPick(results[0]);
setQuery('');
}
}}
before={<Icon size="100" src={Icons.Search} />}
after={
query ? (
<IconButton
size="300"
radii="300"
variant="Background"
aria-label="Clear search"
onClick={() => setQuery('')}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
) : undefined
}
/>
{active && (
<Box direction="Column" role="list" aria-label="Matching settings">
{entries === undefined && (
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
Searching…
</Text>
)}
{entries !== undefined && results.length === 0 && (
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
No settings match “{query.trim()}”.
</Text>
)}
{results.map((r) => (
<MenuItem
key={`${r.page}-${r.title}`}
role="listitem"
variant="Background"
radii="400"
onClick={() => {
onPick(r);
setQuery('');
}}
>
<Box direction="Column" style={{ padding: `${config.space.S100} 0`, minWidth: 0 }}>
<Text size="T300" truncate>
{r.title}
</Text>
<Text size="T200" priority="300" truncate>
{pageNames.get(r.page)}
{r.section && r.section !== r.title ? ` · ${r.section}` : ''}
</Text>
</Box>
</MenuItem>
))}
</Box>
)}
</Box>
);
}
@@ -0,0 +1,20 @@
import { parseSettingsSource, SettingsEntry } from './settingsIndex';
// [Gitea #463] The settings source, as text, in its own lazily loaded chunk:
// fetched the first time someone searches, never at startup.
const sources = import.meta.glob(['../**/*.tsx', '!../**/*.test.tsx'], {
query: '?raw',
import: 'default',
}) as Record<string, () => Promise<string>>;
let index: Promise<SettingsEntry[]> | undefined;
export const loadSettingsIndex = (): Promise<SettingsEntry[]> => {
index ??= Promise.all(
Object.entries(sources).map(async ([path, load]) =>
// '../general/General.tsx' -> '/general/General.tsx' (what the parser matches)
parseSettingsSource(path.replace(/^\.\./, ''), await load()),
),
).then((all) => all.flat());
return index;
};
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { parseSettingsSource, searchSettings } from './settingsIndex';
import { SettingsPages } from '../pages';
const general = fs.readFileSync('src/app/features/settings/general/General.tsx', 'utf8');
const devices = fs.readFileSync('src/app/features/settings/devices/Devices.tsx', 'utf8');
const sys = fs.readFileSync(
'src/app/features/settings/notifications/SystemNotification.tsx',
'utf8',
);
const entries = [
...parseSettingsSource('src/app/features/settings/general/General.tsx', general),
...parseSettingsSource('src/app/features/settings/devices/Devices.tsx', devices),
...parseSettingsSource('src/app/features/settings/notifications/SystemNotification.tsx', sys),
];
const find = (t: string) => entries.find((e) => e.title === t);
test('[#463] indexes the real settings source with the right pages', () => {
assert.equal(find('Push to deafen')?.page, SettingsPages.CallsPage);
assert.equal(find('Strip tracking parameters from links')?.page, SettingsPages.DevicesPage);
assert.equal(find('Page zoom')?.page, SettingsPages.AppearancePage);
assert.equal(find('Enter for a new line')?.page, SettingsPages.MessagesPage);
assert.equal(find('Show hidden events')?.page, SettingsPages.DeveloperToolsPage);
assert.equal(find('Device verification')?.page, SettingsPages.DevicesPage);
assert.equal(find('Quiet hours')?.page, SettingsPages.NotificationPage);
assert.equal(find('Launch on login')?.desktopOnly, true);
assert.match(find('Strip tracking parameters from links')?.description ?? '', /utm_/);
assert.ok(entries.length > 70, `indexed ${entries.length}`);
});
test('[#463] search: words anywhere, title first, desktop-only hidden in a browser', () => {
assert.equal(searchSettings(entries, 'deafen')[0]?.title, 'Push to deafen');
assert.equal(
searchSettings(entries, 'tracking')[0]?.title,
'Strip tracking parameters from links',
);
assert.equal(searchSettings(entries, 'zoom')[0]?.title, 'Page zoom');
assert.ok(searchSettings(entries, 'utm').some((e) => e.title.startsWith('Strip tracking')));
assert.equal(searchSettings(entries, 'launch on login').length, 0);
assert.equal(
searchSettings(entries, 'launch on login', { desktop: true })[0]?.title,
'Launch on login',
);
assert.deepEqual(searchSettings(entries, ' '), []);
});
test('[#463] parser handles the attribute forms and skips dynamic titles', () => {
const src = `
<Text size="L400">Things</Text>
<SettingTile title="Plain" description="A &amp; B" after={<Switch value={x} />} />
<SettingTile title={'Quoted'} description={\`tick\`} />
<SettingTile title={\`Dynamic \${name}\`} />
<SettingTile title={someVar} />`;
const got = parseSettingsSource('src/app/features/settings/about/About.tsx', src);
assert.deepEqual(
got.map((e) => [e.title, e.description, e.section]),
[
['Plain', 'A & B', 'Things'],
['Quoted', 'tick', 'Things'],
['Things', '', undefined],
],
);
});
@@ -0,0 +1,190 @@
import { SettingsPages } from '../pages';
/**
* [Gitea #463] Search across all settings. The index is built from the
* settings source itself (each <SettingTile title=… description=…> and each
* section header), so options added later are found without a hand-kept list.
* Pure: path + source text in, entries out.
*/
export type SettingsEntry = {
title: string;
description: string;
page: SettingsPages;
/** Section header the setting sits under, when known. */
section?: string;
/** Desktop-only (hidden in a browser). */
desktopOnly?: boolean;
};
// General.tsx: which page each section (or helper used by a section) is on.
const GENERAL_SECTIONS: Record<string, SettingsPages> = {
Appearance: SettingsPages.AppearancePage,
SystemThemePreferences: SettingsPages.AppearancePage,
SelectTheme: SettingsPages.AppearancePage,
PageZoomInput: SettingsPages.AppearancePage,
CollapsiblePicker: SettingsPages.AppearancePage,
SeasonalBgGrid: SettingsPages.AppearancePage,
ChatBgGrid: SettingsPages.AppearancePage,
Messages: SettingsPages.MessagesPage,
SelectMessageLayout: SettingsPages.MessagesPage,
SelectMessageSpacing: SettingsPages.MessagesPage,
Editor: SettingsPages.MessagesPage,
ComposerToolbarReorder: SettingsPages.MessagesPage,
ComposerToolbarButtonRow: SettingsPages.MessagesPage,
DateAndTime: SettingsPages.MessagesPage,
SelectDateFormat: SettingsPages.MessagesPage,
DateHint: SettingsPages.MessagesPage,
CustomDateFormat: SettingsPages.MessagesPage,
PresetDateFormat: SettingsPages.MessagesPage,
Calls: SettingsPages.CallsPage,
Privacy: SettingsPages.DevicesPage,
SettingsSyncSection: SettingsPages.DataStoragePage,
AdvancedDisplayOptions: SettingsPages.DeveloperToolsPage,
DesktopChromeSetting: SettingsPages.DesktopPage,
AutostartSetting: SettingsPages.DesktopPage,
KeychainMirrorSetting: SettingsPages.DesktopPage,
DesktopSettings: SettingsPages.DesktopPage,
PackageUpdate: SettingsPages.DesktopPage,
AppUpdates: SettingsPages.DesktopPage,
};
const FOLDER_PAGES: [RegExp, SettingsPages][] = [
[/\/account\//, SettingsPages.AccountPage],
[/\/notifications\//, SettingsPages.NotificationPage],
[/\/devices\//, SettingsPages.DevicesPage],
[/\/developer(-tools)?\//, SettingsPages.DeveloperToolsPage],
[/\/emojis-stickers\//, SettingsPages.EmojisStickersPage],
[/\/about\//, SettingsPages.AboutPage],
[/\/general\/(StorageUsage|YourData)\.tsx$/, SettingsPages.DataStoragePage],
];
const decode = (s: string): string =>
s
.replace(/&amp;/g, '&')
.replace(/&apos;|&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/\\'/g, "'")
.replace(/\s+/g, ' ')
.trim();
/** A JSX attribute's static text: "…", {'…'}, {"…"} or {`…`} without ${}. */
const attr = (tag: string, name: string): string | undefined => {
const m = new RegExp(
`\\b${name}=(?:"([^"]*)"|\\{\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*\\}|\\{\\s*"([^"]*)"\\s*\\}|\\{\\s*\`([^\`$]*)\`\\s*\\})`,
).exec(tag);
const v = m && (m[1] ?? m[2] ?? m[3] ?? m[4]);
return v ? decode(v) : undefined;
};
/** The opening tag starting at `start` (up to the matching '>' outside braces/strings). */
const openingTag = (src: string, start: number): string => {
let depth = 0;
let quote: string | null = null;
for (let i = start; i < src.length; i += 1) {
const c = src[i];
if (quote) {
if (c === quote && src[i - 1] !== '\\') quote = null;
} else if (c === '"' || c === "'" || c === '`') quote = c;
else if (c === '{') depth += 1;
else if (c === '}') depth -= 1;
else if (c === '>' && depth === 0) return src.slice(start, i + 1);
}
return src.slice(start);
};
export const parseSettingsSource = (path: string, src: string): SettingsEntry[] => {
const isGeneral = /\/general\/General\.tsx$/.test(path);
const folderPage = FOLDER_PAGES.find(([re]) => re.test(path))?.[1];
if (!isGeneral && folderPage === undefined) return [];
// For General.tsx: positions of each top-level function, to know the section.
const fns = isGeneral
? [...src.matchAll(/\n(?:export )?function (\w+)\(/g)].map((m) => ({
at: m.index ?? 0,
name: m[1],
}))
: [];
const pageAt = (pos: number): SettingsPages | undefined => {
if (!isGeneral) return folderPage;
let name: string | undefined;
fns.forEach((f) => {
if (f.at < pos) name = f.name;
});
return name ? GENERAL_SECTIONS[name] : undefined;
};
const out: SettingsEntry[] = [];
const headers = [...src.matchAll(/<Text size="L400">([^<{]+)<\/Text>/g)].map((m) => ({
at: m.index ?? 0,
text: decode(m[1]),
}));
const sectionAt = (pos: number): string | undefined => {
let s: string | undefined;
headers.forEach((h) => {
if (h.at < pos) s = h.text;
});
return s;
};
for (const m of src.matchAll(/<SettingTile\b/g)) {
const at = m.index ?? 0;
const tag = openingTag(src, at);
const title = attr(tag, 'title');
const page = pageAt(at);
if (title && page !== undefined) {
out.push({
title,
description: attr(tag, 'description') ?? '',
page,
section: sectionAt(at),
desktopOnly: page === SettingsPages.DesktopPage || undefined,
});
}
}
headers.forEach((h) => {
const page = pageAt(h.at);
if (page !== undefined && !out.some((e) => e.title === h.text && e.page === page)) {
out.push({
title: h.text,
description: '',
page,
desktopOnly: page === SettingsPages.DesktopPage || undefined,
});
}
});
return out;
};
const norm = (s: string): string => s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
/**
* Pure: rank entries for a query. Every word must appear in the title,
* description or section; title matches rank first.
*/
export const searchSettings = (
entries: SettingsEntry[],
query: string,
{ desktop = false, limit = 30 }: { desktop?: boolean; limit?: number } = {},
): SettingsEntry[] => {
const words = norm(query).split(/\s+/).filter(Boolean);
if (words.length === 0) return [];
const scored: { e: SettingsEntry; score: number }[] = [];
entries.forEach((e) => {
if (e.desktopOnly && !desktop) return;
const title = norm(e.title);
const rest = norm(`${e.description} ${e.section ?? ''}`);
let score = 0;
const all = words.every((w) => {
if (title.startsWith(w)) score += 3;
else if (title.includes(w)) score += 2;
else if (rest.includes(w)) score += 1;
else return false;
return true;
});
if (all) scored.push({ e, score });
});
return scored
.sort((a, b) => b.score - a.score || a.e.title.localeCompare(b.e.title))
.slice(0, limit)
.map((s) => s.e);
};
+1 -1
View File
@@ -140,7 +140,7 @@ export function useSettingsSync(): void {
}
/**
* Manual actions for Settings → General → Sync. `pushNow` stamps a fresh
* Manual actions for Settings → Data & storage → Sync. `pushNow` stamps a fresh
* snapshot of THIS device so it wins on every other device; `clearRemote`
* blanks the account-data event (other devices ignore the invalid content and
* keep their local settings until someone pushes again).
+3 -3
View File
@@ -1015,13 +1015,13 @@ function TauriUpdateFeature() {
dismissToast(UPDATE_PROGRESS_TOAST);
if (!failure) return;
// Short on purpose; the full explanation and a Download installer button
// are in Settings → General → App updates.
// are in Settings → Desktop → App updates.
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
setToast({
id: UPDATE_FAILED_TOAST,
iconSrc: Icons.Warning,
displayName: 'Update didn’t install',
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App updates.`,
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → Desktop → App updates.`,
roomName: 'System',
roomId: '',
sticky: true,
@@ -1062,7 +1062,7 @@ function TauriUpdateFeature() {
roomName: 'System',
roomId: '',
onClick: () => {
if (viaPackage) requestSettings('general');
if (viaPackage) requestSettings('desktop');
else installFromToast();
},
sticky: true,
+1 -1
View File
@@ -61,7 +61,7 @@ import { AutoDiscovery } from './AutoDiscovery';
// Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at
// module load so it sees crypto warnings from the very first sync. Idempotent;
// report download lives in Settings → Developer Tools → Crypto Diagnostics.
// report download lives in Settings → Advanced → Crypto diagnostics.
installCryptoDiagLog();
function ClientRootLoading() {
+2 -2
View File
@@ -72,8 +72,8 @@ export function KeyUploadBanner() {
rejects every new upload and other people can&apos;t start fresh encrypted sessions
with you. This usually happens after the browser cleared this site&apos;s storage
while you stayed signed in. Before signing out, make sure this device is verified or
key backup is on (Settings → Devices), so your message history comes back when you
sign in again.
key backup is on (Settings → Privacy & security), so your message history comes back
when you sign in again.
</Text>
{lastError && (
<Text size="T200" align="Center" priority="300" style={{ wordBreak: 'break-word' }}>
+4 -1
View File
@@ -180,7 +180,10 @@ export function SettingsTab() {
useEffect(() => {
if (!settingsRequest) return;
const page = {
general: SettingsPages.GeneralPage,
// [Gitea #459] General is gone; its old deep links land on Appearance.
general: SettingsPages.AppearancePage,
appearance: SettingsPages.AppearancePage,
desktop: SettingsPages.DesktopPage,
account: SettingsPages.AccountPage,
notifications: SettingsPages.NotificationPage,
devices: SettingsPages.DevicesPage,
+7 -1
View File
@@ -1,6 +1,12 @@
import { atom } from 'jotai';
export type SettingsRequestPage = 'general' | 'account' | 'notifications' | 'devices';
export type SettingsRequestPage =
| 'general'
| 'account'
| 'notifications'
| 'devices'
| 'desktop'
| 'appearance';
/**
* Ask the sidebar to open User Settings at a page from anywhere in the app
+76
View File
@@ -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]);
});
+219
View File
@@ -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;
};
+1 -1
View File
@@ -10,7 +10,7 @@
* 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.
* for 90+ days; Review opens Settings → Privacy & security with them ticked to sign out.
*
* Anything not yet known (crypto still loading) → no nudge. Timing: not in
* the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three
+22
View File
@@ -9,3 +9,25 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?:
userId,
});
}
/**
* Resolves once media requests can carry the session: immediately-ish when a
* service worker already controls the page, otherwise when one takes control
* (first visit / just updated) or after `timeoutMs`. Used to retry a media
* load that raced the worker's activation and went out without a token.
*/
export function whenMediaAuthReady(timeoutMs = 3000): Promise<void> {
return new Promise((resolve) => {
const sw = 'serviceWorker' in navigator ? navigator.serviceWorker : undefined;
if (!sw || sw.controller) {
window.setTimeout(resolve, 500);
return;
}
const done = () => {
sw.removeEventListener('controllerchange', done);
resolve();
};
sw.addEventListener('controllerchange', done);
window.setTimeout(done, timeoutMs);
});
}