Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 e8a04fee20 fix(denoise): ship the DeepFilterNet 3 worklet as a file so it starts in the desktop app
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 32s
CI / Build & Quality Checks (pull_request) Successful in 3m17s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 18m41s
Reported: DeepFilterNet 3 "did not start" in a desktop call. Console:
"Loading the script blob:http://127.0.0.1:44548/… violates … script-src",
then "[lotus] denoise setProcessor failed … Failed to load worklet module".
deepfilternet3-noise-filter inlines its AudioWorklet as a string and loads it
from a blob: URL; audio worklets fall under script-src, and the desktop CSP
has no blob: there (the web call page has no script-src, so web was fine).

Instead of loosening the CSP, the build now writes the inline worklet to
denoise/deepfilternet/dfn-worklet.js and patches the copied module to load
it by URL (script-src self). The build fails if the library changes shape.

Verified in Chromium under the exact desktop CSP with the built assets:
before, DeepFilterNet fails as reported; after, it creates its node and, fed
4 s of white noise, cuts it by 60 dB (its configured level). RNNoise, Speex
and DTLN load as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 20:19:02 -04:00
jared 843a48dd10 Merge pull request 'test(e2e): scheduled messages and offline outbox; retry rate-limited scheduled-message calls' (#476) from test/lotus-feature-e2e into lotus
CI / Build & Quality Checks (push) Successful in 3m3s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 24m26s
2026-10-28 07:03:36 -04:00
jared 6d86d25b7f Merge pull request 'fix(presence): your own status shows at once (and clears) in the member list' (#475) from fix/own-presence-display into lotus
CI / Build & Quality Checks (push) Successful in 3m20s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Canceled after 9m52s
2026-10-28 06:50:02 -04:00
Lotus CIandClaude Opus 5.5 56af9520bd test(e2e): scheduled messages and offline outbox; retry rate-limited scheduled-message calls
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 3m16s
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 19m2s
New end-to-end coverage for two Lotus features the October audit listed as
untested, checked against the server at each step:
- scheduled-messages.spec: schedule (tray + one delayed event on the
  server), Send now (arrives once, delayed event gone), cancel (gone from the
  server, never sent).
- offline-outbox.spec: offline, three messages show "Queued. Will send when
  you are back online"; back online, the server has each exactly once and in
  order; the queued marks clear.

The scheduled-messages test found a real bug: Synapse rate-limits delayed
event management per user, and a quick Send now then Cancel got a 429, so
the tray showed "Could not cancel this message" although nothing was wrong.
All scheduled-message calls (schedule, list, send now, cancel, restart) now
wait out a 429 (Retry-After, bounded) and retry; scheduling reuses its txnId.
Unit test for the retry helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 06:40:51 -04:00
Lotus CIandClaude Opus 5.5 c247024bcd fix(presence): your own status shows at once (and clears) in the member list
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 48s
CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 17m34s
Reported: after changing your status it did not appear in the members
drawer until you switched rooms.

The SDK creates our own User object with `new User()` instead of
User.createUser, so its User.* events are never re-emitted on the client.
The shared presence store (PERF-1) listens on the client, so it never heard
our own presence changes; a remount (room switch) re-read the User and
showed it. The store now also refreshes on the ClientEvent.Event the SDK
emits for every sync presence event (after updating the User).

Also read the status from the latest presence event: the SDK only
overwrites presenceStatusMsg with a non-empty status_msg, so a cleared
status lingered (for everyone) until reload.

e2e: set a quick status, close settings, it shows in the drawer; clear it,
it goes. Fails without the fix (status never appears).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-28 06:28:36 -04:00
jared 29ece6a0c8 Merge pull request 'fix(auth): SSO confirmation works in the desktop app; allow switching to SSO' (#474) from fix/sso-uia-desktop into lotus
CI / Secret scan (gitleaks) (push) Successful in 23s
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 29m16s
2026-10-26 12:12:02 -04:00
Lotus CIandClaude Opus 5.5 f7f6059b33 fix(auth): SSO confirmation works in the desktop app; allow switching to SSO
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 1m50s
CI / Build & Quality Checks (pull_request) Successful in 3m15s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 18m29s
Reported by a desktop user: signing out other sessions with SSO looped.
Server logs: the SSO step completed every time, but each delete_devices
retry came without the session (a fresh 401 challenge), 4 attempts, none
succeeded. The app is the Windows desktop build (WebView2) and the SSO
pages opened in the system browser.

- SSOStage only offered "Continue" once window.open returned a window. The
  desktop app opens the page in the system browser (no window back), and a
  popup can lose its opener, so "authDone" never arrives and the only way
  on was starting over. Now "Continue" (plus "Open the sign-in page again")
  appears as soon as the page was opened; authDone still auto-continues.
- ActionUIA: when the server offers password and SSO, the first flow was
  forced (often password, which an SSO-only account may not know). Offer
  "Use SSO instead" / "Use your password instead".

e2e: desktop (window.open returns null) completes with the session; with
password offered first, SSO is one click away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-26 11:49:52 -04:00
jared 71b7619082 Merge pull request 'chore(build): ship only the DTLN files that can load (-9 MB) (#210)' (#470) from chore/build-hygiene into lotus
CI / Build & Quality Checks (push) Successful in 3m41s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 18s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 29m27s
2026-10-25 22:48:04 -04:00
jared e4591f669b Merge pull request 'fix(status): status changes apply at once instead of waiting out the rate limit' (#469) from fix/status-save-speed into lotus
CI / Secret scan (gitleaks) (push) Successful in 19s
CI / Build & Quality Checks (push) Successful in 3m37s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 1m7s
CI / Playwright smoke (e2e) (push) Successful in 29m10s
2026-10-25 21:20:59 -04:00
jared 185970bc5a Merge pull request 'chore(types): typed secret storage key cache; drop the cryptoCallbacks cast (#210)' (#468) from chore/typing-hygiene into lotus
CI / Secret scan (gitleaks) (push) Successful in 33s
CI / Build & Quality Checks (push) Successful in 4m1s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-25 20:33:50 -04:00
Lotus CIandClaude Opus 5.5 d5b0b40b17 chore(build): ship only the DTLN files that can load (-9 MB) (#210)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 24s
CI / Build & Quality Checks (pull_request) Successful in 3m2s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 30m35s
The DTLN noise-suppression package was copied whole into every build
(57 MB). Its threaded LiteRT build is only used on a cross-origin-isolated
page, and neither the web nor the desktop call page is (no COOP/COEP), so
its ~9 MB could never load; source maps and .d.ts files aren't needed at
runtime either. Leave those out: 57 -> 48 MB per web deploy and desktop
installer. Verified on the built output, not isolated: the DTLN worklet
reaches ready and runs a 300-frame benchmark, no 404s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 20:17:20 -04:00
Lotus CIandClaude Opus 5.5 8cdf192b56 fix(status): status changes apply at once instead of waiting out the rate limit
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 3m55s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 33m26s
Synapse allows one presence write per 10 s per user (shared by every
device). The heartbeat wrote presence at startup and on every tab
switch, so a status save right after usually got a 429 and waited 7-25 s,
with the whole status section (quick statuses, input, Clear) locked and
the Save spinner going.

- One shared presence queue (heartbeat + status save), newest write wins:
  changes made while a write waits out a 429 replace it, so only the latest
  is sent, as soon as the server allows.
- The status UI no longer waits on the server: the status applies locally,
  Save switches off (it compared with the server echo, so it stayed lit
  and invited a second, guaranteed-429 write), the controls stay usable,
  and a short note shows until the write lands.
- The heartbeat skips writes the server already reflects (e.g. "online"
  at startup), so it stops spending the budget for nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 20:14:12 -04:00
jared 222927b558 Merge pull request 'feat(settings): search across all settings (#453 phase 3)' (#467) from feat/settings-search into lotus
CI / Build & Quality Checks (push) Successful in 3m27s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 18s
CI / Playwright smoke (e2e) (push) Successful in 21m37s
2026-10-25 19:38:10 -04:00
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
22 changed files with 1292 additions and 71 deletions
+89
View File
@@ -415,6 +415,95 @@ test.describe('local homeserver regression', () => {
.toBe(status);
});
test('status: applies at once, quick changes fold into one write (slow-save report)', async ({
page,
}) => {
let lastOk = 0;
const sent: string[] = [];
await page.route(/\/presence\/[^/]+\/status/, (route) => {
if (route.request().method() !== 'PUT') return route.continue();
const now = Date.now();
if (now - lastOk < 10_000) {
return route.fulfill({
status: 429,
contentType: 'application/json',
body: JSON.stringify({
errcode: 'M_LIMIT_EXCEEDED',
retry_after_ms: 10_000 - (now - lastOk),
}),
});
}
lastOk = now;
sent.push(JSON.parse(route.request().postData() ?? '{}').status_msg ?? '');
return route.continue();
});
await loginUI(page, alice);
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByText('Account', { exact: true }).first().click();
const input = page.getByLabel('Status message');
const save = input.locator('xpath=ancestor::form[1]').getByRole('button', { name: 'Save' });
// Use up the 10 s budget, then pick three quick statuses in a row.
await input.fill(uniq('first '));
await save.click();
await expect(save).toBeDisabled(); // applied: nothing left to save, no spinner wait
const quick = page.getByRole('group', { name: 'Quick statuses' });
await quick.getByRole('button', { name: /Lunch/ }).click();
await quick.getByRole('button', { name: /Watching/ }).click();
await quick.getByRole('button', { name: /AFK/ }).click();
await expect(input).toHaveValue('😴 AFK');
await expect(save).toBeDisabled();
await expect(page.getByText(/Status set\. Sharing it with others/)).toBeVisible();
await expect
.poll(
async () =>
(
await api<{ status_msg?: string }>(
'GET',
`/_matrix/client/v3/presence/${enc(alice.userId)}/status`,
alice.token,
)
).status_msg,
{ timeout: 30_000 },
)
.toBe('😴 AFK');
// The middle choices were superseded while waiting, never sent.
expect(sent.filter((m) => /Lunch|Watching/.test(m))).toEqual([]);
await expect(page.getByText(/Status set\. Sharing it with others/)).toHaveCount(0);
});
test('own status shows in the members drawer without switching rooms, and clears', async ({
page,
}) => {
// The SDK creates our own User with `new User()` (no event re-emit), so the
// shared presence store missed our own presence until a remount. Reported:
// a new status only appeared after switching rooms.
await page.setViewportSize({ width: 1440, height: 900 });
const me = await ensureUser(uniq('own'));
const room = await createRoom(me, 'Own Status Room');
await sendText(me, room, 'hello');
await loginUI(page, me);
await openRoom(page, room);
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click();
await page
.getByRole('group', { name: 'Quick statuses' })
.getByRole('button', { name: /Gaming/ })
.click();
await page.keyboard.press('Escape');
await expect(page.getByText('🎮 Gaming')).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click();
await page
.getByRole('button', { name: /^Clear/ })
.first()
.click();
await page.keyboard.press('Escape');
await expect(page.getByText('🎮 Gaming')).toHaveCount(0, { timeout: 15_000 });
});
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
const page = await ctx.newPage();
+72
View File
@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import {
api,
createRoom,
enc,
ensureUser,
hsReachable,
loginUI,
openRoom,
sendText,
uniq,
} from './localHs';
// Offline outbox (#112): messages sent while offline are queued, then
// delivered once each, in order, when the connection returns. Checked on the
// server, so duplicates or reordering would show.
test.describe('offline outbox', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('queued while offline, delivered once each and in order when back', async ({
page,
context,
}) => {
test.setTimeout(120_000);
const me = await ensureUser(uniq('outbox'));
const room = await createRoom(me, 'Outbox Room');
await sendText(me, room, 'before going offline');
await loginUI(page, me);
await openRoom(page, room);
await expect(page.getByText('before going offline')).toBeVisible();
await context.setOffline(true);
const composer = page.getByRole('textbox', { name: /message/i }).last();
const texts = [uniq('one '), uniq('two '), uniq('three ')];
for (const text of texts) {
// eslint-disable-next-line no-await-in-loop
await composer.fill(text);
// eslint-disable-next-line no-await-in-loop
await composer.press('Enter');
}
for (const text of texts) {
const item = page.locator('[data-message-item]', { hasText: text });
// eslint-disable-next-line no-await-in-loop
await expect(item).toHaveCount(1);
// eslint-disable-next-line no-await-in-loop
await expect(item.getByLabel(/^Queued\. Will send when you're back online/)).toBeVisible({
timeout: 20_000,
});
}
await context.setOffline(false);
const bodiesOnServer = async () => {
const res = await api<{ chunk: { type: string; content?: { body?: string } }[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=f&limit=100`,
me.token,
);
return res.chunk
.filter((e) => e.type === 'm.room.message' && texts.includes(e.content?.body ?? ''))
.map((e) => e.content?.body);
};
await expect.poll(bodiesOnServer, { timeout: 60_000, intervals: [1000] }).toEqual(texts);
for (const text of texts) {
// eslint-disable-next-line no-await-in-loop
await expect(
page.locator('[data-message-item]', { hasText: text }).getByLabel(/^Queued/),
).toHaveCount(0, { timeout: 20_000 });
}
});
});
+102
View File
@@ -0,0 +1,102 @@
import { test, expect, Page } from '@playwright/test';
import {
api,
createRoom,
enc,
ensureUser,
hsReachable,
loginUI,
openRoom,
sendText,
uniq,
} from './localHs';
// Scheduled messages (MSC4140 delayed events). Each step is checked on the
// server too, so this proves the delayed event really exists, fires and is
// cancelled, not just that the tray looks right.
type Delayed = { delayed_events?: { delay_id: string; room_id: string }[] };
const delayedOnServer = (token: string) =>
api<Delayed>('GET', '/_matrix/client/unstable/org.matrix.msc4140/delayed_events', token).then(
(r) => r.delayed_events ?? [],
);
// The tray remembers whether it is open; open it only if its rows are hidden.
const openTray = async (page: Page) => {
const rowButton = page.getByRole('button', { name: /^Cancel scheduled message: / });
if (await rowButton.count()) return;
await page.getByRole('button', { name: /^\d+ scheduled messages?$/ }).click();
await expect(rowButton.first()).toBeVisible();
};
const schedule = async (page: Page, text: string) => {
await page.getByRole('button', { name: 'Schedule message' }).click();
const dialog = page.getByRole('dialog', { name: 'Schedule message' });
await dialog.locator('#schedule-message-body').fill(text);
// Two hours ahead, in the browser's own time zone.
const { date, time } = await page.evaluate(() => {
const d = new Date(Date.now() + 2 * 60 * 60 * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return {
date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
time: `${p(d.getHours())}:${p(d.getMinutes())}`,
};
});
await dialog.locator('#schedule-date').fill(date);
await dialog.locator('#schedule-time').fill(time);
await dialog.getByRole('button', { name: 'Schedule', exact: true }).click();
await expect(dialog).toHaveCount(0);
};
test.describe('scheduled messages', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('schedule, send now, and cancel — the server agrees at each step', async ({ page }) => {
test.setTimeout(120_000);
const me = await ensureUser(uniq('sched'));
const room = await createRoom(me, 'Schedule Room');
await sendText(me, room, 'room is ready');
await loginUI(page, me);
await openRoom(page, room);
// 1. Schedule: tray shows it, the server holds one delayed event.
const first = uniq('later ');
await schedule(page, first);
await expect(page.getByRole('button', { name: '1 scheduled message' })).toBeVisible();
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(1);
expect((await delayedOnServer(me.token))[0].room_id).toBe(room);
await expect(page.locator('[data-message-item]', { hasText: first })).toHaveCount(0);
// 2. Send now: it arrives once, the delayed event is gone.
await openTray(page);
await page.getByRole('button', { name: /^Send scheduled message now: / }).click();
await expect(page.locator('[data-message-item]', { hasText: first })).toHaveCount(1, {
timeout: 20_000,
});
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(0);
// 3. Cancel: removed from the server and never sent.
const second = uniq('never ');
await schedule(page, second);
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(1);
await openTray(page);
await page.getByRole('button', { name: /^Cancel scheduled message: / }).click();
await expect
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
.toBe(0);
await expect(page.getByRole('button', { name: /scheduled message/ })).toHaveCount(0);
const msgs = await api<{ chunk: { content?: { body?: string } }[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=50`,
me.token,
);
expect(msgs.chunk.some((e) => e.content?.body === second)).toBe(false);
});
});
+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();
});
});
+83
View File
@@ -0,0 +1,83 @@
import { test, expect, Page } from '@playwright/test';
import { api, ensureUser, hsReachable, loginUI, PASSWORD } from './localHs';
// Signing out other devices with SSO confirmation. The desktop app opens the SSO
// page in the system browser (window.open gives no window back), so the dialog
// must still offer "Continue" — before the fix the only way on was starting
// over, in a loop (reported by a desktop SSO user). The server is mocked: the
// retry succeeds only if it carries the challenge's session.
const openDevicesAndSignOutOther = async (page: Page, flows: string[][]) => {
const sent: unknown[] = [];
await page.route('**/delete_devices', async (route) => {
const body = JSON.parse(route.request().postData() ?? '{}');
sent.push(body.auth ?? null);
if (body.auth?.session === 'S1') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
}
return route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
session: 'S1',
flows: flows.map((stages) => ({ stages })),
params: {},
}),
});
});
await page.getByRole('button', { name: 'User Settings' }).click();
const dialog = page.getByRole('dialog').first();
await dialog.getByText('Privacy & security', { exact: true }).first().click();
const card = dialog
.getByText('Old laptop', { exact: true })
.locator('xpath=ancestor::*[.//button][1]');
await card.getByRole('button').nth(1).click(); // the delete chip
const bar = dialog.getByText(/Logout from selected devices/);
await bar
.locator('xpath=ancestor::*[.//button[normalize-space()="Logout"]][1]')
.getByRole('button', { name: 'Logout', exact: true })
.click();
return { sent, bar };
};
test.describe('SSO confirmation for signing out devices', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('desktop: no popup window, Continue still completes it', async ({ page }) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
window.open = () => null; // the desktop app hands the page to the system browser
});
const user = await ensureUser(`ssod${Date.now().toString(36)}`);
await api('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: user.localpart },
password: PASSWORD,
initial_device_display_name: 'Old laptop',
});
await loginUI(page, user);
const { sent, bar } = await openDevicesAndSignOutOther(page, [['m.login.sso']]);
await page.getByRole('button', { name: 'Continue with SSO' }).click();
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(bar).toHaveCount(0);
expect(sent).toEqual([null, { session: 'S1' }]);
});
test('password offered first: SSO is still one click away', async ({ page }) => {
test.setTimeout(120_000);
const user = await ensureUser(`ssop${Date.now().toString(36)}`);
await api('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: user.localpart },
password: PASSWORD,
initial_device_display_name: 'Old laptop',
});
await loginUI(page, user);
await openDevicesAndSignOutOther(page, [['m.login.password'], ['m.login.sso']]);
await expect(page.getByText('Account Password').first()).toBeVisible();
await page.getByRole('button', { name: 'Use SSO instead' }).click();
await expect(page.getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Use your password instead' })).toBeVisible();
});
});
+30 -3
View File
@@ -1,4 +1,5 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useState } from 'react';
import { Box, Button, Text } from 'folds';
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { getUIAFlowForStages } from '../utils/matrix-uia';
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
@@ -23,7 +24,18 @@ type ActionUIAProps = {
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
const mx = useMatrixClient();
const completed = useUIACompleted(authData);
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
// The server may offer several ways to confirm (e.g. password or SSO). Start
// with the given one but let the user switch: an account that signs in with
// SSO may have no password it knows.
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
const [flow, setFlow] = useState<UIAFlow>(ongoingFlow);
const { getStageToComplete } = useUIAFlow(authData, flow);
const other = supportedFlows.find((f) => f.stages.join() !== flow.stages.join());
const otherLabel = other?.stages.includes(AuthType.Sso)
? 'Use SSO instead'
: other?.stages.includes(AuthType.Password)
? 'Use your password instead'
: undefined;
const stageToComplete = getStageToComplete();
@@ -31,7 +43,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
return (
<UIAFlowOverlay
currentStep={completed.length + 1}
stepCount={ongoingFlow.stages.length}
stepCount={flow.stages.length}
onCancel={onCancel}
>
{stageToComplete.type === AuthType.Password && (
@@ -50,6 +62,21 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
submitAuthDict={action}
/>
)}
{other && otherLabel && completed.length === 0 && (
<Box justifyContent="Center" style={{ marginTop: 8 }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setFlow(other)}
>
<Text as="span" size="B300">
{otherLabel}
</Text>
</Button>
</Box>
)}
</UIAFlowOverlay>
);
}
@@ -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,
+23 -7
View File
@@ -12,6 +12,12 @@ export function SSOStage({
}) {
const { errorCode, error, session } = stageData;
const [ssoWindow, setSSOWindow] = useState<Window>();
// Whether the SSO page was opened. Not the same as having a window handle:
// the desktop app opens it in the system browser and gets none back, and a
// popup can lose its link to us (cross-origin opener policy on the identity
// provider). In both cases "authDone" never arrives, so "Continue" must still
// be offered once the page was opened, or the only way on is starting over.
const [started, setStarted] = useState(false);
const handleSubmit = useCallback(() => {
submitAuthDict({
@@ -22,6 +28,7 @@ export function SSOStage({
const handleContinue = () => {
const w = window.open(ssoRedirectURL, '_blank');
setSSOWindow(w ?? undefined);
setStarted(true);
};
useEffect(() => {
@@ -74,7 +81,9 @@ export function SSOStage({
gap="400"
>
<Text size="T200">
To perform this action you need to authenticate yourself by SSO login.
{started
? 'Finish signing in on the page that opened, then come back here and select Continue.'
: 'To do this, confirm it’s you by signing in with SSO.'}
</Text>
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
@@ -85,12 +94,19 @@ export function SSOStage({
</Box>
)}
{ssoWindow ? (
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
{started ? (
<Box direction="Column" gap="200">
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
<Button variant="Secondary" fill="None" onClick={handleContinue}>
<Text as="span" size="B400">
Open the sign-in page again
</Text>
</Button>
</Box>
) : (
<Button variant="Primary" onClick={handleContinue}>
<Text as="span" size="B400">
+53 -2
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Avatar,
Box,
@@ -13,6 +13,7 @@ import {
OverlayBackdrop,
OverlayCenter,
Text,
color,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import {
@@ -43,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 };
@@ -94,6 +97,49 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
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) {
@@ -134,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}
+24 -22
View File
@@ -38,11 +38,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
import {
describePresenceError,
setPresenceWithRetry,
writePresence,
} from '../../../utils/presenceWrite';
import { describePresenceError, queuePresenceWrite } from '../../../utils/presenceWrite';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
@@ -419,6 +415,10 @@ function ProfileStatus() {
// until our own echo lands or a short window elapses (bounded so a genuinely
// dropped echo can't block real cross-device updates forever).
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
// The status as this device last applied or synced it. "Save" compares with
// this, not with the server echo, so it switches off the moment a status is
// applied instead of staying lit until the (rate-limited) write lands.
const [savedMsg, setSavedMsg] = useState<string>(initialStatus);
const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const { presets, addPreset, removePreset } = useStatusPresets();
@@ -442,7 +442,7 @@ function ProfileStatus() {
if (pending) {
if (remoteStatus === pending.value) {
pendingAppliedRef.current = null; // our own echo landed — accept it
} else if (Date.now() - pending.ts < 15_000) {
} else if (Date.now() - pending.ts < 30_000) {
return; // stale echo of the previous status; ignore within the window
} else {
pendingAppliedRef.current = null; // window elapsed — accept external changes
@@ -451,6 +451,7 @@ function ProfileStatus() {
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
// typed: don't show it in the status field or keep it as their status.
if (remoteStatus === DND_STATUS_SENTINEL) return;
setSavedMsg(remoteStatus);
if (remoteStatus) {
setStatusMsg(remoteStatus);
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
@@ -467,9 +468,10 @@ function ProfileStatus() {
useCallback(
(msg: string) =>
// Synapse allows ONE presence write per 10 s per user (shared by all
// devices) and our heartbeat spends that budget too — wait out a 429
// instead of failing the user's save.
setPresenceWithRetry(mx, {
// devices) and our heartbeat spends that budget too. The shared queue
// waits out a 429 and folds quick successive changes into one write;
// the UI doesn't wait on it (the status is applied locally at once).
queuePresenceWrite(mx, {
// Derive presence from the user's chosen setting so writing a status
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
presence: presenceStateFromSetting(presenceStatus, hidePresence),
@@ -498,6 +500,7 @@ function ProfileStatus() {
(rawMsg: string, clearAfterValue: string) => {
statusDirtyRef.current = false;
const msg = rawMsg.trim();
setSavedMsg(msg);
// Guard against a stale presence echo reverting this value (see the sync effect).
pendingAppliedRef.current = { value: msg, ts: Date.now() };
noteLocalStatusWrite();
@@ -527,19 +530,17 @@ function ProfileStatus() {
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (saving) return;
applyStatus(statusMsg, clearAfter);
};
// Preset click = one-click apply: reflect it in the inputs and save immediately.
const applyPreset = useCallback(
(preset: StatusPreset) => {
if (saving) return;
setStatusMsg(preset.label);
setClearAfter(preset.clearAfter);
applyStatus(preset.label, preset.clearAfter);
},
[saving, applyStatus],
[applyStatus],
);
const handleSaveCurrent = useCallback(() => {
@@ -560,17 +561,18 @@ function ProfileStatus() {
pendingAppliedRef.current = { value: '', ts: Date.now() };
noteLocalStatusWrite();
setStatusMsg('');
setSavedMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
writeStatusExpiry(mx, '').catch(() => undefined);
// Preserve the user's chosen presence when clearing the status message.
writePresence(mx, {
queuePresenceWrite(mx, {
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: '',
}).catch(() => undefined);
};
const hasChanges = statusMsg !== (presence?.status ?? '');
const hasChanges = statusMsg.trim() !== savedMsg;
return (
<SettingTile
@@ -599,7 +601,6 @@ function ProfileStatus() {
variant="Secondary"
fill="Soft"
radii="Pill"
disabled={saving}
onClick={() => applyPreset(preset)}
>
<Text as="span" size="B300">
@@ -625,7 +626,6 @@ function ProfileStatus() {
variant="Secondary"
fill="Soft"
radii="Pill"
disabled={saving}
onClick={() => applyPreset(preset)}
>
<Text as="span" size="B300">
@@ -651,7 +651,7 @@ function ProfileStatus() {
fill="Soft"
radii="Pill"
outlined
disabled={saving || !statusMsg.trim()}
disabled={!statusMsg.trim()}
onClick={handleSaveCurrent}
before={<Icon size="100" src={Icons.Plus} />}
>
@@ -662,7 +662,7 @@ function ProfileStatus() {
</Box>
</Box>
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
<Box grow="Yes" direction="Column" gap="100">
<Input
name="statusMsgInput"
@@ -672,7 +672,6 @@ function ProfileStatus() {
placeholder="What's on your mind?"
variant="Secondary"
radii="300"
readOnly={saving}
maxLength={64}
/>
<Text
@@ -733,13 +732,17 @@ function ProfileStatus() {
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || saving}
disabled={!hasChanges}
type="submit"
>
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
{saving && (
<Text size="T200" priority="300" role="status">
Status set. Sharing it with others, this can take a few seconds.
</Text>
)}
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{describePresenceError(saveState.error)}
@@ -764,7 +767,6 @@ function ProfileStatus() {
radii="300"
type="button"
onClick={handleClear}
disabled={saving}
>
<Text size="B300">Clear Status</Text>
</Button>
@@ -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);
};
+19 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { SetPresence } from 'matrix-js-sdk';
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
import { PresenceDeduper, PresenceWrite, queuePresenceWrite } from '../utils/presenceWrite';
import { useMatrixClient } from './useMatrixClient';
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
import { useSetting } from '../state/hooks/settings';
@@ -70,9 +70,25 @@ export function usePresenceUpdater() {
// by all devices) and /sync already keeps us online — only send when the
// state or status actually changes, so a manual status save isn't 429'd
// by a heartbeat that changed nothing.
// What the server reported in the last resolveStatus() (consumed by send).
let serverNow: { presence: string; status_msg?: string } | undefined;
const send = (write: PresenceWrite, label: string) => {
const server = serverNow;
serverNow = undefined;
if (!deduper.shouldSend(write)) return Promise.resolve();
return writePresence(mx, write)
// The server already shows exactly this (e.g. /sync keeps us online and
// the status is unchanged): writing it again would only spend the
// 10-second budget a status save may need next.
if (
server &&
server.presence === write.presence &&
(server.status_msg ?? '') === (write.status_msg ?? '')
) {
deduper.sent(write);
return Promise.resolve();
}
// Shared queue with the status save: waits out a 429, newest write wins.
return queuePresenceWrite(mx, write)
.then(() => deduper.sent(write))
.catch((err) => warnPresenceFailure(label, err));
};
@@ -84,6 +100,7 @@ export function usePresenceUpdater() {
const local = readStatus();
if (!userId) return local;
const server = await mx.getPresence(userId).catch(() => undefined);
serverNow = server;
const status = pickHeartbeatStatus({
local,
server,
+29 -2
View File
@@ -1,5 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import { MatrixClient, User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import {
ClientEvent,
MatrixClient,
MatrixEvent,
User,
UserEvent,
UserEventHandlerMap,
} from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
export enum Presence {
@@ -19,7 +26,12 @@ export type UserPresence = {
const getUserPresence = (user: User): UserPresence => ({
presence: user.presence as Presence,
status: user.presenceStatusMsg,
// From the latest presence event rather than user.presenceStatusMsg: the SDK
// only overwrites that field with a non-empty status_msg, so a cleared status
// would otherwise linger until reload.
status: user.events.presence
? (user.events.presence.getContent().status_msg as string | undefined) || undefined
: user.presenceStatusMsg,
active: user.currentlyActive,
lastActiveTs: user.getLastActiveTs(),
});
@@ -55,6 +67,19 @@ class PresenceStore {
subs.forEach((cb) => cb());
};
// Our own User object is created by the SDK with `new User()` (not
// User.createUser), so its User.* events are never re-emitted on the client
// and the listeners above miss our own presence changes. The client does
// emit every sync presence event as ClientEvent.Event (after updating the
// User), so refresh from that too.
private handleClientEvent = (event: MatrixEvent): void => {
if (event.getType() !== 'm.presence' || !this.mx) return;
const userId = event.getSender();
if (!userId) return;
const user = this.mx.getUser(userId);
if (user) this.handleEvent(event, user);
};
private start(mx: MatrixClient): void {
if (this.started && this.mx === mx) return;
// A different client (re-login without reload) — drop the old listeners.
@@ -62,12 +87,14 @@ class PresenceStore {
this.mx.removeListener(UserEvent.Presence, this.handleEvent);
this.mx.removeListener(UserEvent.CurrentlyActive, this.handleEvent);
this.mx.removeListener(UserEvent.LastPresenceTs, this.handleEvent);
this.mx.removeListener(ClientEvent.Event, this.handleClientEvent);
this.cache.clear();
}
this.mx = mx;
mx.on(UserEvent.Presence, this.handleEvent);
mx.on(UserEvent.CurrentlyActive, this.handleEvent);
mx.on(UserEvent.LastPresenceTs, this.handleEvent);
mx.on(ClientEvent.Event, this.handleClientEvent);
this.started = true;
}
+48 -2
View File
@@ -1,7 +1,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixError } from 'matrix-js-sdk';
import { PresenceDeduper, describePresenceError, setPresenceWithRetry } from './presenceWrite';
import { IPresenceOpts, MatrixError } from 'matrix-js-sdk';
import {
PresenceDeduper,
describePresenceError,
isPresenceWritePending,
queuePresenceWrite,
setPresenceWithRetry,
} from './presenceWrite';
const limited = (retryMs = 3000) =>
new MatrixError(
@@ -79,3 +85,43 @@ test('error wording', () => {
);
assert.match(describePresenceError(new TypeError('Failed to fetch')), /couldn't reach/);
});
test('[status save] queuePresenceWrite: newest wins, waits out 429 once, all callers settle', async () => {
const sent: string[] = [];
let calls = 0;
const sleeps: number[] = [];
const mx = {
setPresence: async (w: IPresenceOpts) => {
calls += 1;
if (calls === 1) {
sent.push(`429:${w.status_msg}`);
throw new MatrixError({ errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 7000 }, 429);
}
sent.push(`ok:${w.status_msg}`);
},
};
const sleepFn = async (ms: number) => {
sleeps.push(ms);
};
// 'a' goes out at once; 'b' and 'c' arrive while it waits out the limit.
const first = queuePresenceWrite(mx, { presence: 'online', status_msg: 'a' }, { sleepFn });
const second = queuePresenceWrite(mx, { presence: 'online', status_msg: 'b' }, { sleepFn });
const third = queuePresenceWrite(mx, { presence: 'online', status_msg: 'c' }, { sleepFn });
assert.equal(isPresenceWritePending(mx), true);
await Promise.all([first, second, third]);
// 'a' was 429'd; after the wait only the newest ('c') goes out — 'a' isn't
// retried and 'b' is never sent.
assert.deepEqual(sent, ['429:a', 'ok:c']);
assert.deepEqual(sleeps, [7000]);
assert.equal(isPresenceWritePending(mx), false);
});
test('[status save] queuePresenceWrite: a failure rejects the callers of that write', async () => {
const mx = {
setPresence: async () => {
throw new MatrixError({ errcode: 'M_FORBIDDEN' }, 403);
},
};
await assert.rejects(queuePresenceWrite(mx, { presence: 'online', status_msg: 'x' }));
assert.equal(isPresenceWritePending(mx), false);
});
+75
View File
@@ -94,3 +94,78 @@ export const describePresenceError = (e: unknown): string => {
}
return "Failed to save status — couldn't reach the server. Try again.";
};
type PresenceQueue = {
pending?: PresenceWrite;
waiters: { resolve: () => void; reject: (e: unknown) => void }[];
running: boolean;
};
const queues = new WeakMap<object, PresenceQueue>();
/**
* One presence writer per client, newest write wins. The status save and the
* heartbeat share Synapse's one-write-per-10-s budget, so instead of each
* waiting out 429s on its own (and racing), writes queue here: anything still
* waiting is replaced by the newer write, which goes out as soon as the server
* allows. Each caller's promise settles with the outcome of the write that
* finally carried (or superseded) theirs.
*/
export function queuePresenceWrite(
mx: Pick<MatrixClient, 'setPresence'>,
write: PresenceWrite,
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
): Promise<void> {
let q = queues.get(mx);
if (!q) {
q = { waiters: [], running: false };
queues.set(mx, q);
}
const queue = q;
queue.pending = write;
const settled = new Promise<void>((resolve, reject) => {
queue.waiters.push({ resolve, reject });
});
if (!queue.running) {
queue.running = true;
const maxWaitMs = opts.maxWaitMs ?? 25_000;
const sleepFn = opts.sleepFn ?? sleep;
(async () => {
let waited = 0;
while (queue.pending) {
const next = queue.pending;
queue.pending = undefined;
const waiters = queue.waiters.splice(0);
try {
// eslint-disable-next-line no-await-in-loop
await writePresence(mx, next);
waited = 0;
if (queue.pending) queue.waiters.unshift(...waiters);
else waiters.forEach((w) => w.resolve());
} catch (e) {
const wait =
e instanceof MatrixError && e.httpStatus === 429
? Math.min(e.getRetryAfterMs() ?? 10_500, 15_000)
: undefined;
if (wait !== undefined && waited + wait <= maxWaitMs) {
waited += wait;
// eslint-disable-next-line no-await-in-loop
await sleepFn(wait);
// After the wait, send whatever is newest: a change made meanwhile
// replaces this one rather than queueing behind it.
queue.pending ??= next;
queue.waiters.unshift(...waiters);
} else if (queue.pending) {
queue.waiters.unshift(...waiters);
} else {
waiters.forEach((w) => w.reject(e));
}
}
}
queue.running = false;
})();
}
return settled;
}
/** Whether a presence write is queued or in flight for this client. */
export const isPresenceWritePending = (mx: object): boolean => queues.get(mx)?.running ?? false;
+40
View File
@@ -191,3 +191,43 @@ test('[#275] the server limit maps to friendly text; other errors keep theirs',
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
});
test('withRateLimitRetry: waits out a 429 then succeeds; other errors and over-budget waits throw', async () => {
const { withRateLimitRetry } = await import('./scheduledMessages');
const { MatrixError } = await import('matrix-js-sdk');
const limited = () => new MatrixError({ errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1500 }, 429);
const sleeps: number[] = [];
const sleepFn = async (ms: number) => {
sleeps.push(ms);
};
let calls = 0;
const ok = await withRateLimitRetry(
async () => {
calls += 1;
if (calls < 3) throw limited();
return 'done';
},
{ sleepFn },
);
assert.equal(ok, 'done');
assert.deepEqual(sleeps, [1500, 1500]);
await assert.rejects(
withRateLimitRetry(
async () => {
throw new MatrixError({ errcode: 'M_FORBIDDEN' }, 403);
},
{ sleepFn },
),
/M_FORBIDDEN|403/,
);
await assert.rejects(
withRateLimitRetry(
async () => {
throw limited();
},
{ sleepFn, maxWaitMs: 2000 },
),
);
});
+73 -29
View File
@@ -1,4 +1,4 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
import { IContent, MatrixClient, MatrixError, Method } from 'matrix-js-sdk';
import type { ScheduledMessage } from '../state/scheduledMessages';
/**
@@ -17,6 +17,40 @@ export const describeScheduleError = (err: unknown): string => {
return err instanceof Error ? err.message : 'Failed to schedule message.';
};
/**
* Synapse rate-limits delayed-event management per user (listing, send now,
* cancel, restart all count). A quick "Send now" then "Cancel" could get a 429
* and show "Could not cancel" although nothing was wrong. Wait out the
* Retry-After (bounded) and try again. Retrying is safe: a 429 means the
* request was not processed, and scheduling reuses its txnId.
*/
export async function withRateLimitRetry<T>(
fn: () => Promise<T>,
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
): Promise<T> {
const maxWaitMs = opts.maxWaitMs ?? 15_000;
const sleepFn =
opts.sleepFn ??
((ms: number) =>
new Promise<void>((r) => {
setTimeout(r, ms);
}));
let waited = 0;
for (;;) {
try {
// eslint-disable-next-line no-await-in-loop
return await fn();
} catch (e) {
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
const wait = Math.min(Math.max(e.getRetryAfterMs() ?? 1000, 200), 10_000);
if (waited + wait > maxWaitMs) throw e;
waited += wait;
// eslint-disable-next-line no-await-in-loop
await sleepFn(wait);
}
}
}
/**
* Schedule a message via MSC4140 (delayed messages).
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
@@ -42,13 +76,15 @@ export async function scheduleMessage(
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
// Use standard v3 prefix with the MSC4140 delay query param.
const res = (await mx.http.authedRequest(
Method.Put,
path,
{
'org.matrix.msc4140.delay': delayMs,
},
content,
const res = (await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Put,
path,
{
'org.matrix.msc4140.delay': delayMs,
},
content,
),
)) as { delay_id: string };
return res.delay_id;
}
@@ -58,23 +94,27 @@ export async function scheduleMessage(
*/
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'cancel' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'cancel' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
);
}
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'restart' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'restart' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
);
}
@@ -85,12 +125,14 @@ export async function restartScheduledMessage(mx: MatrixClient, delayId: string)
*/
export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
);
}
@@ -138,9 +180,11 @@ export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
/** The user's pending scheduled messages, as the server has them. */
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
});
const res = await withRateLimitRetry(() =>
mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
}),
);
return parseDelayedEvents(res);
}
+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);
});
}
+42 -1
View File
@@ -136,6 +136,36 @@ function denoiseSources() {
};
}
// deepfilternet3-noise-filter inlines its AudioWorklet as a string and loads it
// from a blob: URL. Audio worklets fall under script-src, and the desktop app's
// CSP (rightly) has no blob: there, so DeepFilterNet 3 failed to start ("denoise
// setProcessor failed … Failed to load worklet module script: blob:…"). Write
// the worklet out as a real file next to the module and load it by URL instead,
// which script-src 'self' allows everywhere. Fails the build if the library's
// shape changes, rather than shipping a model that cannot start.
const DFN_WORKLET_FILE = 'dfn-worklet.js';
const DFN_BLOB_LOAD = ` const blob = new Blob([workletCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
await audioContext.audioWorklet.addModule(blobUrl);`;
function dfnWorkletToFile(esmPath) {
const src = fs.readFileSync(esmPath, 'utf8');
const literal = src.match(/^var workletCode = (".*");\s*$/m)?.[1];
const code = literal ? JSON.parse(literal) : undefined;
if (!code?.includes('registerProcessor(') || !src.includes(DFN_BLOB_LOAD)) {
throw new Error(
`[lotus-denoise] deepfilternet3-noise-filter changed shape (inline worklet / blob loader not found in ${esmPath}) — build aborted; update dfnWorkletToFile in vite.config.js.`,
);
}
fs.writeFileSync(path.join(path.dirname(esmPath), DFN_WORKLET_FILE), code);
fs.writeFileSync(
esmPath,
src.replace(
DFN_BLOB_LOAD,
` // [lotus] Loaded from a file, not a blob: URL (CSP script-src 'self').\n await audioContext.audioWorklet.addModule(new URL('./${DFN_WORKLET_FILE}', import.meta.url).href);`,
),
);
}
// Lotus ML noise suppression: ship the denoise worklets/wasm/models alongside
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
// dist/public/element-call (closeBundle order). [Gitea #293] The dev server
@@ -189,7 +219,17 @@ function lotusDenoise() {
'Run `npm ci` to install @workadventure/noise-suppression.',
);
}
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), { recursive: true });
// [Gitea #210] Ship only what can load. The threaded LiteRT build is used
// only on a cross-origin-isolated page (COOP + COEP), and neither the web
// call page nor the desktop one is, so its ~9 MB never loads; source maps
// and type declarations aren't needed at runtime either. If the call page
// ever turns on COOP/COEP, drop the `threaded` exclusion.
const dtlnSkip = (src) =>
/litert_wasm_threaded_internal\./.test(src) || /\.(map|d\.ts)$/.test(src);
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), {
recursive: true,
filter: (src) => !dtlnSkip(src),
});
const dfnMissing = dfn.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (dfnMissing.length > 0) {
@@ -202,6 +242,7 @@ function lotusDenoise() {
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d);
});
dfnWorkletToFile(path.join(denoiseDir, 'deepfilternet/index.esm.js'));
// [lotus] DENOISE CUTOVER: the getUserMedia shim is no longer injected.
// Our forked Element Call now runs ML denoise in-source as a real LiveKit