Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8794465034 | ||
|
|
ace084c91c | ||
|
|
c73a3f8b31 | ||
|
|
504899aa14 | ||
|
|
7d31b2b2f7 | ||
|
|
61d2433721 | ||
|
|
78b19f1ced | ||
|
|
2f5bcf2b91 | ||
|
|
d1a9625b1c | ||
|
|
9ab01ff6f6 | ||
|
|
d2c3c56b18 | ||
|
|
e2ce177930 | ||
|
|
1ebc42863e | ||
|
|
58f7b18746 | ||
|
|
43445db5cc | ||
|
|
3c33169297 | ||
|
|
3163829c57 | ||
|
|
2264c79833 | ||
|
|
2612f71c57 | ||
|
|
e8a04fee20 | ||
|
|
843a48dd10 | ||
|
|
6d86d25b7f | ||
|
|
56af9520bd | ||
|
|
c247024bcd | ||
|
|
29ece6a0c8 | ||
|
|
f7f6059b33 | ||
|
|
71b7619082 | ||
|
|
e4591f669b | ||
|
|
185970bc5a | ||
|
|
d5b0b40b17 | ||
|
|
8cdf192b56 | ||
|
|
222927b558 | ||
|
|
b944456ab5 | ||
|
|
02bd356a89 | ||
|
|
3b3bf8abac | ||
|
|
22d5aea5e6 | ||
|
|
826fc46229 | ||
|
|
b93e4bcef9 | ||
|
|
1af491cabb | ||
|
|
449d0c740e |
+2
-1
@@ -16,7 +16,8 @@
|
||||
"statusPages": {
|
||||
"matrix.lotusguild.org": {
|
||||
"url": "https://isitup.lotusguild.org",
|
||||
"slug": "matrix",
|
||||
"slug": "public",
|
||||
"noticePrefix": "Matrix:",
|
||||
"groups": {
|
||||
"homeserver": "Homeserver",
|
||||
"calls": "Voice calls",
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
- navigation "Settings sections":
|
||||
- button "General" [pressed]:
|
||||
- img
|
||||
- paragraph: General
|
||||
- button "Account":
|
||||
- button "Account" [pressed]:
|
||||
- img
|
||||
- paragraph: Account
|
||||
- button "Appearance":
|
||||
- img
|
||||
- paragraph: Appearance
|
||||
- button "Messages":
|
||||
- img
|
||||
- paragraph: Messages
|
||||
- button "Notifications":
|
||||
- img
|
||||
- paragraph: Notifications
|
||||
- button "Devices":
|
||||
- button "Calls & audio":
|
||||
- img
|
||||
- paragraph: Devices
|
||||
- button "Emojis & Stickers":
|
||||
- paragraph: Calls & audio
|
||||
- button "Privacy & security":
|
||||
- img
|
||||
- paragraph: Emojis & Stickers
|
||||
- button "Developer Tools":
|
||||
- paragraph: Privacy & security
|
||||
- button "Data & storage":
|
||||
- img
|
||||
- paragraph: Developer Tools
|
||||
- paragraph: Data & storage
|
||||
- button "Emojis & stickers":
|
||||
- img
|
||||
- paragraph: Emojis & stickers
|
||||
- button "Advanced":
|
||||
- img
|
||||
- paragraph: Advanced
|
||||
- button "About":
|
||||
- img
|
||||
- paragraph: About
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe('key upload recovery (#201)', () => {
|
||||
expect(rejected).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await banner.getByRole('button', { name: 'Details' }).click();
|
||||
await expect(banner).toContainText('Settings → Devices');
|
||||
await expect(banner).toContainText('Settings → Privacy & security');
|
||||
await expect(banner).toContainText('already exists');
|
||||
|
||||
await banner.getByRole('button', { name: /Fix: sign out/ }).click();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ test.describe('your data (#114)', () => {
|
||||
.catch(() => undefined);
|
||||
const openGeneral = async () => {
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
await page.getByRole('dialog').getByText('General', { exact: true }).first().click();
|
||||
await page.getByRole('dialog').getByText('Data & storage', { exact: true }).first().click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByText('Your data', { exact: true })
|
||||
|
||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.4",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.28.0-lotus.2",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.2/element-call-embedded-0.28.0-lotus.2.tgz",
|
||||
"integrity": "sha512-g3xaZ/i3RQ0mToWmjLi08HjSuKD3BpAllj9aEucPS8wh3Uwmz4HXa0tgF1hEN3dPrZR2BTWLQii5Mtfh4037vg==",
|
||||
"version": "0.28.0-lotus.4",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.4/element-call-embedded-0.28.0-lotus.4.tgz",
|
||||
"integrity": "sha512-g+1mkdpGqUkE7JJo3b2G0XBiwDFwEedWul20hwD0iBpDyW67OafjbhrzRwfROY8Q9bx6BnulJHiR6Ll98KjguA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.4",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import { SrOnly } from '../features/call-status/styles.css';
|
||||
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||
import { useCallQuality } from '../hooks/useCallQuality';
|
||||
import { useCallDenoise } from '../hooks/useCallDenoise';
|
||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
@@ -836,6 +837,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
useMutedTalkWarning(embed, joined);
|
||||
useCallThemeSync(embed);
|
||||
useCallQuality(embed);
|
||||
useCallDenoise(embed);
|
||||
useCallHangupEvent(
|
||||
embed,
|
||||
useCallback(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Chip, Icon, IconSrc, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import {
|
||||
BRIDGE_NETWORK_NAME,
|
||||
BridgeNetwork,
|
||||
describeRoomBridge,
|
||||
getBridgedUserNetwork,
|
||||
} from '../../utils/bridge';
|
||||
import { useRoomBridges } from '../../hooks/useRoomBridges';
|
||||
|
||||
// Discord glyph (simple-icons, 24x24 viewBox like the folds icons).
|
||||
const DiscordIcon: IconSrc = () => (
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
|
||||
/>
|
||||
);
|
||||
|
||||
const BRIDGE_ICON: Record<BridgeNetwork, IconSrc> = {
|
||||
discord: DiscordIcon,
|
||||
};
|
||||
|
||||
const BRIDGE_COLOR: Record<BridgeNetwork, string> = {
|
||||
discord: '#5865F2',
|
||||
};
|
||||
|
||||
type UserBridgeBadgeProps = {
|
||||
userId: string;
|
||||
size?: '50' | '100' | '200';
|
||||
};
|
||||
|
||||
/** Network icon for a user who is bridged in from another chat network, else nothing. */
|
||||
export function UserBridgeBadge({ userId, size = '100' }: UserBridgeBadgeProps) {
|
||||
const network = getBridgedUserNetwork(userId);
|
||||
if (!network) return null;
|
||||
const label = `On ${BRIDGE_NETWORK_NAME[network]} (bridged)`;
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{label}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
||||
>
|
||||
<Icon size={size} src={BRIDGE_ICON[network]} style={{ color: BRIDGE_COLOR[network] }} />
|
||||
</span>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type RoomBridgeChipProps = {
|
||||
room: Room;
|
||||
};
|
||||
|
||||
/** Header chip for a room bridged to another network; links to the channel when known. */
|
||||
export function RoomBridgeChip({ room }: RoomBridgeChipProps) {
|
||||
const bridges = useRoomBridges(room);
|
||||
const bridge = bridges[0];
|
||||
if (!bridge) return null;
|
||||
const description = describeRoomBridge(bridge);
|
||||
const linkProps = bridge.externalUrl
|
||||
? { as: 'a' as const, href: bridge.externalUrl, target: '_blank', rel: 'noreferrer noopener' }
|
||||
: {};
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>
|
||||
{bridge.externalUrl
|
||||
? `${description}. Open in ${BRIDGE_NETWORK_NAME[bridge.network]}.`
|
||||
: description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Chip
|
||||
ref={triggerRef}
|
||||
size="400"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
aria-label={description}
|
||||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={BRIDGE_ICON[bridge.network]}
|
||||
style={{ color: BRIDGE_COLOR[bridge.network] }}
|
||||
/>
|
||||
}
|
||||
{...linkProps}
|
||||
>
|
||||
<Text size="T200">{BRIDGE_NETWORK_NAME[bridge.network]}</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './BridgeBadge';
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -27,6 +27,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useLocalTime } from '../../hooks/useLocalTime';
|
||||
import { UserBridgeBadge } from '../bridge';
|
||||
|
||||
type UserHeroProps = {
|
||||
userId: string;
|
||||
@@ -143,6 +144,7 @@ export function UserHeroName({
|
||||
>
|
||||
{displayName ?? username ?? userId}
|
||||
</Text>
|
||||
<UserBridgeBadge userId={userId} size="200" />
|
||||
</Box>
|
||||
{pronouns && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -71,6 +71,7 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/use
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
||||
import { UserBridgeBadge } from '../../components/bridge';
|
||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
||||
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||
@@ -178,6 +179,7 @@ function MemberItem({
|
||||
{presence && (
|
||||
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
|
||||
)}
|
||||
<UserBridgeBadge userId={member.userId} />
|
||||
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
|
||||
{typing && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { RoomBridgeChip } from '../../components/bridge';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
@@ -690,6 +691,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<RoomBridgeChip room={room} />
|
||||
</Box>
|
||||
{topic && (
|
||||
<UseStateProvider initial={false}>
|
||||
|
||||
@@ -90,6 +90,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
||||
import { PowerIcon } from '../../../components/power';
|
||||
import { UserBridgeBadge } from '../../../components/bridge';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||
@@ -1084,6 +1085,7 @@ export const Message = React.memo(
|
||||
</Text>
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
<UserBridgeBadge userId={senderId} />
|
||||
</Box>
|
||||
<Box shrink="No" gap="100">
|
||||
{messageLayout === MessageLayout.Modern && hover && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -313,7 +309,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Display Name
|
||||
Display name
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
@@ -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,23 +561,24 @@ 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
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Status Message
|
||||
Status message
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DenoiseNode,
|
||||
buildGateNode,
|
||||
buildModelNode,
|
||||
monoDownmix,
|
||||
readDb,
|
||||
sampleRateFor,
|
||||
} from '../../../utils/denoisePipeline';
|
||||
@@ -139,7 +140,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
inAnalyser.fftSize = 1024;
|
||||
source.connect(inAnalyser);
|
||||
|
||||
let head: AudioNode = source;
|
||||
let head: AudioNode = source.connect(monoDownmix(ctx));
|
||||
let gate: AudioWorkletNode | null = null;
|
||||
if (useGate) {
|
||||
gate = await buildGateNode(ctx, gateThreshold);
|
||||
@@ -308,10 +309,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = clip;
|
||||
const mono = source.connect(monoDownmix(ctx));
|
||||
let playGate: AudioWorkletNode | null = null;
|
||||
let playModelNode: DenoiseNode | null = null;
|
||||
if (playModel) {
|
||||
let head: AudioNode = source;
|
||||
let head: AudioNode = mono;
|
||||
if (useGate) {
|
||||
playGate = await buildGateNode(ctx, gateThreshold);
|
||||
head.connect(playGate);
|
||||
@@ -321,7 +323,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
head.connect(playModelNode.node);
|
||||
playModelNode.node.connect(ctx.destination);
|
||||
} else {
|
||||
source.connect(ctx.destination);
|
||||
mono.connect(ctx.destination);
|
||||
}
|
||||
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
||||
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { Box, Button, Text, color } from 'folds';
|
||||
import { Box, Button, RadioButton, Text, color } from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
@@ -224,22 +224,24 @@ export function YourDataSection() {
|
||||
here:
|
||||
</Text>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
<RadioButton
|
||||
name="your-data-conflicts"
|
||||
variant="Primary"
|
||||
size="300"
|
||||
checked={!preferFile}
|
||||
onChange={() => setPreferFile(false)}
|
||||
onClick={() => setPreferFile(false)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Keep mine
|
||||
</Text>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
<RadioButton
|
||||
name="your-data-conflicts"
|
||||
variant="Primary"
|
||||
size="300"
|
||||
checked={preferFile}
|
||||
onChange={() => setPreferFile(true)}
|
||||
onClick={() => setPreferFile(true)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Use the file's
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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(/&/g, '&')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/"/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);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { buildDenoisePayload } from '../utils/callDenoise';
|
||||
|
||||
/**
|
||||
* Apply noise-suppression setting changes to a call that's already running
|
||||
* (the fork's `io.lotus.set_denoise`): switching between off, browser-native
|
||||
* and an ML model, or between models, takes effect without rejoining. The join
|
||||
* itself still carries the settings in the call URL, so the first send is a
|
||||
* no-op fork-side.
|
||||
*/
|
||||
export function useCallDenoise(embed: CallEmbed): void {
|
||||
const {
|
||||
callNoiseSuppression,
|
||||
callDenoiseModel,
|
||||
callDenoiseNativeNS,
|
||||
callDenoiseGate,
|
||||
callDenoiseGateThreshold,
|
||||
} = useAtomValue(settingsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
embed.control.setDenoise(
|
||||
buildDenoisePayload({
|
||||
callNoiseSuppression,
|
||||
callDenoiseModel,
|
||||
callDenoiseNativeNS,
|
||||
callDenoiseGate,
|
||||
callDenoiseGateThreshold,
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
embed,
|
||||
callNoiseSuppression,
|
||||
callDenoiseModel,
|
||||
callDenoiseNativeNS,
|
||||
callDenoiseGate,
|
||||
callDenoiseGateThreshold,
|
||||
]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useStateEventCallback } from './useStateEventCallback';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
import { getRoomBridges, RoomBridgeInfo } from '../utils/bridge';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
|
||||
export const useRoomBridges = (room: Room): RoomBridgeInfo[] => {
|
||||
const [updateCount, forceUpdate] = useForceUpdate();
|
||||
|
||||
useStateEventCallback(
|
||||
room.client,
|
||||
useCallback(
|
||||
(event) => {
|
||||
const type = event.getType();
|
||||
if (
|
||||
event.getRoomId() === room.roomId &&
|
||||
(type === StateEvent.Bridge || type === StateEvent.BridgeUnstable)
|
||||
) {
|
||||
forceUpdate();
|
||||
}
|
||||
},
|
||||
[room, forceUpdate],
|
||||
),
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => getRoomBridges(room),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[room, updateCount],
|
||||
);
|
||||
};
|
||||
@@ -62,7 +62,7 @@ export const useServerStatus = (
|
||||
]);
|
||||
if (stopped) return;
|
||||
failures = 0;
|
||||
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
|
||||
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups, page!.noticePrefix);
|
||||
setStatus(last);
|
||||
// While this client's connection is lost, keep asking every minute: that
|
||||
// is how it learns Kuma has checked the server since the drop.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1015,13 +1015,13 @@ function TauriUpdateFeature() {
|
||||
dismissToast(UPDATE_PROGRESS_TOAST);
|
||||
if (!failure) return;
|
||||
// Short on purpose; the full explanation and a Download installer button
|
||||
// are in Settings → General → App updates.
|
||||
// are in Settings → Desktop → App updates.
|
||||
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
|
||||
setToast({
|
||||
id: UPDATE_FAILED_TOAST,
|
||||
iconSrc: Icons.Warning,
|
||||
displayName: 'Update didn’t install',
|
||||
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App updates.`,
|
||||
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → Desktop → App updates.`,
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
sticky: true,
|
||||
@@ -1062,7 +1062,7 @@ function TauriUpdateFeature() {
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
onClick: () => {
|
||||
if (viaPackage) requestSettings('general');
|
||||
if (viaPackage) requestSettings('desktop');
|
||||
else installFromToast();
|
||||
},
|
||||
sticky: true,
|
||||
|
||||
@@ -61,7 +61,7 @@ import { AutoDiscovery } from './AutoDiscovery';
|
||||
|
||||
// Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at
|
||||
// module load so it sees crypto warnings from the very first sync. Idempotent;
|
||||
// report download lives in Settings → Developer Tools → Crypto Diagnostics.
|
||||
// report download lives in Settings → Advanced → Crypto diagnostics.
|
||||
installCryptoDiagLog();
|
||||
|
||||
function ClientRootLoading() {
|
||||
|
||||
@@ -72,8 +72,8 @@ export function KeyUploadBanner() {
|
||||
rejects every new upload and other people can't start fresh encrypted sessions
|
||||
with you. This usually happens after the browser cleared this site's storage
|
||||
while you stayed signed in. Before signing out, make sure this device is verified or
|
||||
key backup is on (Settings → Devices), so your message history comes back when you
|
||||
sign in again.
|
||||
key backup is on (Settings → Privacy & security), so your message history comes back
|
||||
when you sign in again.
|
||||
</Text>
|
||||
{lastError && (
|
||||
<Text size="T200" align="Center" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
|
||||
@@ -180,7 +180,10 @@ export function SettingsTab() {
|
||||
useEffect(() => {
|
||||
if (!settingsRequest) return;
|
||||
const page = {
|
||||
general: SettingsPages.GeneralPage,
|
||||
// [Gitea #459] General is gone; its old deep links land on Appearance.
|
||||
general: SettingsPages.AppearancePage,
|
||||
appearance: SettingsPages.AppearancePage,
|
||||
desktop: SettingsPages.DesktopPage,
|
||||
account: SettingsPages.AccountPage,
|
||||
notifications: SettingsPages.NotificationPage,
|
||||
devices: SettingsPages.DevicesPage,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CallControlState } from './CallControlState';
|
||||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||
import { canDelegateCapability } from './utils';
|
||||
import { CallErrorState, parseCallError } from './callError';
|
||||
import type { LotusDenoisePayload } from '../../utils/callDenoise';
|
||||
|
||||
export enum CallControlEvent {
|
||||
StateUpdate = 'state_update',
|
||||
@@ -106,6 +107,11 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
|
||||
private lastQuality: LotusQualityPayload | null = null;
|
||||
|
||||
// Last noise-suppression choice (io.lotus.set_denoise), held like
|
||||
// lastQuality: sent once joined and re-sent after an EC reconnect, whose
|
||||
// remounted handler starts again from the settings in the join URL.
|
||||
private lastDenoise: LotusDenoisePayload | null = null;
|
||||
|
||||
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
|
||||
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
|
||||
// user-initiated unmute that auto-undeafens the user.
|
||||
@@ -191,6 +197,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.joined = true;
|
||||
this.sendDeafenState();
|
||||
this.sendQuality();
|
||||
this.sendDenoise();
|
||||
this.sendHotkeyCodes();
|
||||
this.sendFrameScreenshareAllowed();
|
||||
}
|
||||
@@ -204,6 +211,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
public resendForkState(): void {
|
||||
this.sendDeafenState();
|
||||
this.sendQuality();
|
||||
this.sendDenoise();
|
||||
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
|
||||
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
||||
@@ -762,6 +770,21 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch noise suppression on the live mic (the fork's `io.lotus.set_denoise`):
|
||||
* browser-native on/off and/or the ML model, without rejoining. Join-gated
|
||||
* like setQuality().
|
||||
*/
|
||||
public setDenoise(payload: LotusDenoisePayload): void {
|
||||
this.lastDenoise = payload;
|
||||
this.sendDenoise();
|
||||
}
|
||||
|
||||
private sendDenoise(): void {
|
||||
if (!this.joined || !this.lastDenoise) return;
|
||||
this.call.transport.send('io.lotus.set_denoise', this.lastDenoise).catch(() => undefined);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
||||
this.clearFocusParticipant();
|
||||
|
||||
@@ -133,6 +133,15 @@ export class CallEmbed {
|
||||
|
||||
private themeKind: ElementCallThemeKind = 'dark';
|
||||
|
||||
// The theme the frame was loaded with (its URL `theme` param), and whether
|
||||
// the widget API is ready. Until it is, the iframe may still be on its
|
||||
// initial about:blank (our own origin), so a send to the call's origin is
|
||||
// refused ("target origin … does not match the recipient window's origin")
|
||||
// and the request hangs to a timeout. Theme changes wait for ready.
|
||||
private loadedThemeKind: ElementCallThemeKind = 'dark';
|
||||
|
||||
private widgetReady = false;
|
||||
|
||||
// Watchdog: detects an iframe that never reaches a usable state.
|
||||
private loadWatchdog?: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -228,10 +237,14 @@ export class CallEmbed {
|
||||
denoiseMode === 'browser' ||
|
||||
(denoiseMode === 'ml' && denoiseNativeNS)
|
||||
).toString(),
|
||||
// Turn the browser's auto gain control OFF for the ML tier only: its
|
||||
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
|
||||
// tiers keep the browser's normal capture pipeline (AGC on).
|
||||
autoGainControl: (denoiseMode !== 'ml').toString(),
|
||||
// Keep the browser's auto gain control ON in every tier. It used to be
|
||||
// turned off for the ML tier ("pumping"), but AGC is what brings a quiet
|
||||
// mic up to speaking level: without it ML-tier voices went out 10-20 dB
|
||||
// quieter than the browser tier, while the models themselves keep speech
|
||||
// within ~1.5 dB (measured: RNNoise, Speex, DTLN, DeepFilterNet3). Chrome's
|
||||
// AGC2 only adapts during speech, and the model runs after it, so it still
|
||||
// removes the noise AGC leaves in the pauses.
|
||||
autoGainControl: 'true',
|
||||
audio: initialAudio.toString(),
|
||||
video: initialVideo.toString(),
|
||||
header: 'none',
|
||||
@@ -360,6 +373,7 @@ export class CallEmbed {
|
||||
this.iframe = iframe;
|
||||
this.container = container;
|
||||
this.themeKind = themeKind;
|
||||
this.loadedThemeKind = themeKind;
|
||||
|
||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||
this.control = new CallControl(controlState, call, iframe);
|
||||
@@ -375,6 +389,13 @@ export class CallEmbed {
|
||||
// happy path (onCallJoined) clears it too; these cover earlier signals so a
|
||||
// user sitting in the lobby/prescreen isn't flagged as an error.
|
||||
this.disposables.push(this.onReady(() => this.settleLoad()));
|
||||
this.disposables.push(
|
||||
this.onReady(() => {
|
||||
this.widgetReady = true;
|
||||
// A theme change made while the frame was loading.
|
||||
if (this.themeKind !== this.loadedThemeKind) this.sendTheme();
|
||||
}),
|
||||
);
|
||||
this.disposables.push(this.onCapabilitiesNotified(() => this.settleLoad()));
|
||||
this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));
|
||||
|
||||
@@ -399,12 +420,18 @@ export class CallEmbed {
|
||||
return this.room.roomId;
|
||||
}
|
||||
|
||||
public setTheme(theme: ElementCallThemeKind) {
|
||||
public setTheme(theme: ElementCallThemeKind): Promise<unknown> {
|
||||
this.themeKind = theme;
|
||||
// Before ready, the URL theme applies and the change is sent on ready.
|
||||
if (!this.widgetReady) return Promise.resolve();
|
||||
return this.sendTheme();
|
||||
}
|
||||
|
||||
private sendTheme(): Promise<unknown> {
|
||||
return this.call.transport
|
||||
.send(WidgetApiToWidgetAction.ThemeChange, { name: theme })
|
||||
.send(WidgetApiToWidgetAction.ThemeChange, { name: this.themeKind })
|
||||
.catch(() => {
|
||||
// Widget transport not ready yet; theme URL param is the fallback
|
||||
// The frame went away mid-send; nothing to do.
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -377,9 +377,11 @@ const defaultSettings: Settings = {
|
||||
|
||||
cameraOnJoin: false,
|
||||
// Tier default stays browser-native (known-good; best-perceived in testing so
|
||||
// far). If a user opts into the ML tier, default to the highest-quality model.
|
||||
// far). If a user opts into the ML tier, default to DTLN: in our measurements
|
||||
// it keeps voices the most natural, removes fans and typing well, and costs a
|
||||
// third of DeepFilterNet's CPU.
|
||||
callNoiseSuppression: 'browser',
|
||||
callDenoiseModel: 'deepfilternet',
|
||||
callDenoiseModel: 'dtln',
|
||||
// "Series suppression" (stack the browser's native NS before the ML model) is
|
||||
// off by default — best practice is a single NS stage; it's an opt-in test aid.
|
||||
callDenoiseNativeNS: false,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export type SettingsRequestPage = 'general' | 'account' | 'notifications' | 'devices';
|
||||
export type SettingsRequestPage =
|
||||
| 'general'
|
||||
| 'account'
|
||||
| 'notifications'
|
||||
| 'devices'
|
||||
| 'desktop'
|
||||
| 'appearance';
|
||||
|
||||
/**
|
||||
* Ask the sidebar to open User Settings at a page from anywhere in the app
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
describeRoomBridge,
|
||||
getBridgedUserNetwork,
|
||||
getRoomBridges,
|
||||
parseBridgeEvent,
|
||||
} from './bridge';
|
||||
|
||||
const bridgeEvent = (type: string, stateKey: string, content: Record<string, unknown>) =>
|
||||
new MatrixEvent({ type, state_key: stateKey, content, room_id: '!r:x', sender: '@discordbot:x' });
|
||||
|
||||
// Shape of what mautrix-discord 0.7.7 sends for a guild channel.
|
||||
const discordContent = {
|
||||
bridgebot: '@discordbot:matrix.lotusguild.org',
|
||||
protocol: { id: 'discordgo', displayname: 'Discord' },
|
||||
channel: {
|
||||
id: '1565159372122882070',
|
||||
displayname: '#general',
|
||||
external_url: 'https://discord.com/channels/1/1565159372122882070',
|
||||
},
|
||||
network: { id: '1', displayname: 'Lotus Guild' },
|
||||
};
|
||||
|
||||
const roomWithState = (events: MatrixEvent[]): Room =>
|
||||
({
|
||||
getLiveTimeline: () => ({
|
||||
getState: () => ({
|
||||
getStateEvents: (type: string) => events.filter((e) => e.getType() === type),
|
||||
}),
|
||||
}),
|
||||
}) as unknown as Room;
|
||||
|
||||
test('Discord ghosts are recognised; the bridge bot and lookalikes are not', () => {
|
||||
assert.equal(
|
||||
getBridgedUserNetwork('@discord_123456789012345678:matrix.lotusguild.org'),
|
||||
'discord',
|
||||
);
|
||||
assert.equal(getBridgedUserNetwork('@discordbot:matrix.lotusguild.org'), undefined);
|
||||
assert.equal(getBridgedUserNetwork('@discord_fan:matrix.lotusguild.org'), undefined);
|
||||
assert.equal(getBridgedUserNetwork('@discord-relay:matrix.lotusguild.org'), undefined);
|
||||
assert.equal(getBridgedUserNetwork('@jared:matrix.lotusguild.org'), undefined);
|
||||
});
|
||||
|
||||
test('parseBridgeEvent reads a mautrix-discord m.bridge event', () => {
|
||||
assert.deepEqual(parseBridgeEvent(bridgeEvent('m.bridge', 'k', discordContent)), {
|
||||
network: 'discord',
|
||||
channelName: '#general',
|
||||
networkName: 'Lotus Guild',
|
||||
externalUrl: 'https://discord.com/channels/1/1565159372122882070',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseBridgeEvent ignores unknown protocols and empty content', () => {
|
||||
assert.equal(
|
||||
parseBridgeEvent(bridgeEvent('m.bridge', 'k', { protocol: { id: 'telegram' } })),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(parseBridgeEvent(bridgeEvent('m.bridge', 'k', {})), undefined);
|
||||
});
|
||||
|
||||
test('only http(s) channel links are kept', () => {
|
||||
const content = {
|
||||
...discordContent,
|
||||
channel: { displayname: '#x', external_url: ['javascript', 'alert(1)'].join(':') },
|
||||
};
|
||||
assert.equal(parseBridgeEvent(bridgeEvent('m.bridge', 'k', content))?.externalUrl, undefined);
|
||||
});
|
||||
|
||||
test('getRoomBridges merges m.bridge with its unstable copy, one entry per channel', () => {
|
||||
const room = roomWithState([
|
||||
bridgeEvent('m.bridge', 'fi.mau.discord://discord/1/2', discordContent),
|
||||
bridgeEvent('uk.half-shot.bridge', 'fi.mau.discord://discord/1/2', discordContent),
|
||||
]);
|
||||
assert.equal(getRoomBridges(room).length, 1);
|
||||
assert.equal(getRoomBridges(roomWithState([])).length, 0);
|
||||
});
|
||||
|
||||
test('describeRoomBridge degrades gracefully without names', () => {
|
||||
assert.equal(
|
||||
describeRoomBridge({ network: 'discord', channelName: '#general', networkName: 'Lotus Guild' }),
|
||||
'Bridged to #general in Lotus Guild on Discord',
|
||||
);
|
||||
assert.equal(describeRoomBridge({ network: 'discord' }), 'Bridged to Discord');
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getStateEvents } from './room';
|
||||
|
||||
export type BridgeNetwork = 'discord';
|
||||
|
||||
export const BRIDGE_NETWORK_NAME: Record<BridgeNetwork, string> = {
|
||||
discord: 'Discord',
|
||||
};
|
||||
|
||||
// Ghost (puppet) users a bridge creates for people on the other network.
|
||||
// mautrix-discord's default username_template is discord_{{.}} with a numeric
|
||||
// Discord snowflake; the bridge bot itself (@discordbot) is not a ghost.
|
||||
const GHOST_PATTERNS: [RegExp, BridgeNetwork][] = [[/^@discord_\d+:/, 'discord']];
|
||||
|
||||
export const getBridgedUserNetwork = (userId: string): BridgeNetwork | undefined =>
|
||||
GHOST_PATTERNS.find(([pattern]) => pattern.test(userId))?.[1];
|
||||
|
||||
// m.bridge protocol.id values that mean each network.
|
||||
const PROTOCOL_NETWORKS: Record<string, BridgeNetwork> = {
|
||||
discordgo: 'discord',
|
||||
discord: 'discord',
|
||||
};
|
||||
|
||||
export type RoomBridgeInfo = {
|
||||
network: BridgeNetwork;
|
||||
channelName?: string;
|
||||
networkName?: string;
|
||||
externalUrl?: string;
|
||||
};
|
||||
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
|
||||
// Only http(s) links are made clickable; the bridge (or anyone with state
|
||||
// power in the room) controls this value.
|
||||
const safeExternalUrl = (value: unknown): string | undefined => {
|
||||
const raw = asString(value);
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseBridgeEvent = (event: MatrixEvent): RoomBridgeInfo | undefined => {
|
||||
const content = event.getContent();
|
||||
const protocolId = asString(content?.protocol?.id)?.toLowerCase();
|
||||
const network = protocolId ? PROTOCOL_NETWORKS[protocolId] : undefined;
|
||||
if (!network) return undefined;
|
||||
return {
|
||||
network,
|
||||
channelName: asString(content.channel?.displayname),
|
||||
networkName: asString(content.network?.displayname),
|
||||
externalUrl: safeExternalUrl(content.channel?.external_url),
|
||||
};
|
||||
};
|
||||
|
||||
/** Known bridges in a room, one per bridged channel (m.bridge preferred over the unstable copy). */
|
||||
export const getRoomBridges = (room: Room): RoomBridgeInfo[] => {
|
||||
const byKey = new Map<string, RoomBridgeInfo>();
|
||||
[StateEvent.BridgeUnstable, StateEvent.Bridge].forEach((type) => {
|
||||
getStateEvents(room, type).forEach((event) => {
|
||||
const info = parseBridgeEvent(event);
|
||||
if (info) byKey.set(event.getStateKey() ?? '', info);
|
||||
});
|
||||
});
|
||||
return Array.from(byKey.values());
|
||||
};
|
||||
|
||||
export const describeRoomBridge = (info: RoomBridgeInfo): string => {
|
||||
const network = BRIDGE_NETWORK_NAME[info.network];
|
||||
if (info.channelName && info.networkName) {
|
||||
return `Bridged to ${info.channelName} in ${info.networkName} on ${network}`;
|
||||
}
|
||||
if (info.channelName) return `Bridged to ${info.channelName} on ${network}`;
|
||||
return `Bridged to ${network}`;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildDenoisePayload } from './callDenoise';
|
||||
|
||||
test('buildDenoisePayload passes the settings through', () => {
|
||||
assert.deepEqual(
|
||||
buildDenoisePayload({
|
||||
callNoiseSuppression: 'ml',
|
||||
callDenoiseModel: 'dtln',
|
||||
callDenoiseNativeNS: false,
|
||||
callDenoiseGate: true,
|
||||
callDenoiseGateThreshold: -50,
|
||||
}),
|
||||
{ mode: 'ml', model: 'dtln', nativeNS: false, gate: true, gateThreshold: -50 },
|
||||
);
|
||||
});
|
||||
|
||||
test('buildDenoisePayload uses the same defaults as the join URL', () => {
|
||||
assert.deepEqual(buildDenoisePayload({}), {
|
||||
mode: 'browser',
|
||||
model: 'rnnoise',
|
||||
nativeNS: true,
|
||||
gate: false,
|
||||
gateThreshold: -45,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DenoiseModelId, NoiseSuppressionMode } from '../state/settings';
|
||||
|
||||
/**
|
||||
* Payload for the fork's `io.lotus.set_denoise` action, which switches noise
|
||||
* suppression on the live mic mid-call (browser suppressor on/off and/or the
|
||||
* ML model) instead of needing a rejoin.
|
||||
*/
|
||||
export type LotusDenoisePayload = {
|
||||
mode: NoiseSuppressionMode;
|
||||
model: DenoiseModelId;
|
||||
nativeNS: boolean;
|
||||
gate: boolean;
|
||||
gateThreshold: number;
|
||||
};
|
||||
|
||||
export type DenoiseSettings = {
|
||||
callNoiseSuppression?: NoiseSuppressionMode;
|
||||
callDenoiseModel?: DenoiseModelId;
|
||||
callDenoiseNativeNS?: boolean;
|
||||
callDenoiseGate?: boolean;
|
||||
callDenoiseGateThreshold?: number;
|
||||
};
|
||||
|
||||
/** Same defaults as the call URL built at join (useCallStart/createCallEmbed). */
|
||||
export const buildDenoisePayload = (s: DenoiseSettings): LotusDenoisePayload => ({
|
||||
mode: s.callNoiseSuppression ?? 'browser',
|
||||
model: s.callDenoiseModel ?? 'rnnoise',
|
||||
nativeNS: s.callDenoiseNativeNS ?? true,
|
||||
gate: s.callDenoiseGate ?? false,
|
||||
gateThreshold: s.callDenoiseGateThreshold ?? -45,
|
||||
});
|
||||
@@ -21,6 +21,20 @@ const BASE = `${import.meta.env.BASE_URL.replace(/\/+$/, '')}/public/element-cal
|
||||
*/
|
||||
export const sampleRateFor = (model: DenoiseModelId): number => (model === 'dtln' ? 16000 : 48000);
|
||||
|
||||
/**
|
||||
* A pass-through node that downmixes whatever feeds it to ONE channel. Every
|
||||
* model only processes channel 0, and a recorded clip often decodes as stereo
|
||||
* (Chrome's Opus/WebM), so without this RNNoise/Speex played the cleaned audio
|
||||
* in the left ear only. Lotus Call does the same (#24). A mono output is then
|
||||
* upmixed to both ears by the destination.
|
||||
*/
|
||||
export const monoDownmix = (ctx: BaseAudioContext): GainNode =>
|
||||
new GainNode(ctx, {
|
||||
channelCount: 1,
|
||||
channelCountMode: 'explicit',
|
||||
channelInterpretation: 'speakers',
|
||||
});
|
||||
|
||||
export type DenoiseNode = {
|
||||
node: AudioWorkletNode;
|
||||
dispose: () => void;
|
||||
@@ -85,7 +99,10 @@ export async function buildModelNode(
|
||||
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
|
||||
const core = new mod.DeepFilterNet3Core({
|
||||
sampleRate: sampleRateFor(model),
|
||||
noiseReductionLevel: 80,
|
||||
// Same attenuation limit as Lotus Call (dB). Higher values mute the
|
||||
// background outright, so words drop into dead silence and sound
|
||||
// clipped at the end.
|
||||
noiseReductionLevel: 25,
|
||||
assetConfig: { cdnUrl: dfnBase },
|
||||
});
|
||||
await core.initialize();
|
||||
@@ -105,6 +122,8 @@ export async function buildModelNode(
|
||||
const [, wasmBinary] = await Promise.all([addModuleOnce(ctx, cfg.script), fetchWasm(cfg.wasm)]);
|
||||
const node = new AudioWorkletNode(ctx, cfg.proc, {
|
||||
channelCount: 1,
|
||||
channelCountMode: 'explicit',
|
||||
channelInterpretation: 'speakers',
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
processorOptions: { maxChannels: 1, wasmBinary },
|
||||
@@ -127,6 +146,9 @@ export async function buildGateNode(
|
||||
): Promise<AudioWorkletNode> {
|
||||
await addModuleOnce(ctx, 'noiseGateWorklet.js');
|
||||
return new AudioWorkletNode(ctx, '@sapphi-red/web-noise-suppressor/noise-gate', {
|
||||
channelCount: 1,
|
||||
channelCountMode: 'explicit',
|
||||
channelInterpretation: 'speakers',
|
||||
processorOptions: {
|
||||
openThreshold: thresholdDb,
|
||||
closeThreshold: thresholdDb - 5,
|
||||
|
||||
@@ -285,3 +285,75 @@ test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)',
|
||||
'Read the guide docs',
|
||||
);
|
||||
});
|
||||
|
||||
test('[noticePrefix] resolveStatusPage keeps it only when set', () => {
|
||||
const base = { url: 'https://kuma.example', slug: 'public' };
|
||||
assert.equal(
|
||||
resolveStatusPage({ hs: { ...base, noticePrefix: ' Matrix: ' } }, 'hs')?.noticePrefix,
|
||||
'Matrix:',
|
||||
);
|
||||
assert.equal(resolveStatusPage({ hs: base }, 'hs')?.noticePrefix, undefined);
|
||||
});
|
||||
|
||||
test('[noticePrefix] on a shared page only prefixed notices reach users, without the prefix', () => {
|
||||
const page = {
|
||||
publicGroupList: [],
|
||||
maintenanceList: [
|
||||
{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' },
|
||||
{ id: 2, title: 'matrix: Synapse upgrade', status: 'under-maintenance' },
|
||||
],
|
||||
incidents: [
|
||||
{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' },
|
||||
{ id: 4, title: 'Matrix: Calls are flaky', content: 'y', style: 'warning' },
|
||||
{ id: 5, title: 'Matrix:', content: 'nothing left after the prefix', style: 'info' },
|
||||
{ id: 6, title: 'Matrixish news', content: 'z', style: 'info' },
|
||||
],
|
||||
};
|
||||
const s = parseKumaStatus(page, {}, DEFAULT_GROUPS, 'Matrix:');
|
||||
assert.deepEqual(
|
||||
s.maintenance.map((m) => [m.id, m.title]),
|
||||
[['2', 'Synapse upgrade']],
|
||||
);
|
||||
assert.deepEqual(
|
||||
s.incidents.map((i) => [i.id, i.title]),
|
||||
[['4', 'Calls are flaky']],
|
||||
);
|
||||
});
|
||||
|
||||
test('[noticePrefix] without one, every notice shows as before', () => {
|
||||
const page = {
|
||||
publicGroupList: [],
|
||||
maintenanceList: [{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' }],
|
||||
incidents: [{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' }],
|
||||
};
|
||||
const s = parseKumaStatus(page, {}, DEFAULT_GROUPS);
|
||||
assert.equal(s.maintenance[0].title, 'Jellyfin upgrade');
|
||||
assert.equal(s.incidents[0].title, 'Storage redundancy restored');
|
||||
});
|
||||
|
||||
test('[split public page] a Discord group going down touches neither homeserver nor calls', () => {
|
||||
const up = [
|
||||
{ status: 1, time: '2026-11-05 01:00:00' },
|
||||
{ status: 1, time: '2026-11-05 01:01:00' },
|
||||
];
|
||||
const down = [
|
||||
{ status: 0, time: '2026-11-05 01:00:00' },
|
||||
{ status: 0, time: '2026-11-05 01:01:00' },
|
||||
];
|
||||
const page = {
|
||||
publicGroupList: [
|
||||
{ name: 'Homeserver', monitorList: [{ id: 30 }] },
|
||||
{ name: 'Voice calls', monitorList: [{ id: 37 }] },
|
||||
{ name: 'Discord', monitorList: [{ id: 49 }] },
|
||||
{ name: 'Services', monitorList: [{ id: 27 }] },
|
||||
],
|
||||
};
|
||||
const s = parseKumaStatus(
|
||||
page,
|
||||
{ heartbeatList: { 30: up, 37: up, 49: down, 27: down } },
|
||||
DEFAULT_GROUPS,
|
||||
);
|
||||
assert.equal(s.homeserver, 'up');
|
||||
assert.equal(s.calls, 'up');
|
||||
assert.equal(s.login, 'unknown');
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
* "login": "Login" } } }
|
||||
* Users of any other homeserver never contact Kuma.
|
||||
*
|
||||
* Optional "noticePrefix": on a status page shared with other services, only
|
||||
* incidents and maintenance titled with this prefix (e.g. "Matrix:") reach
|
||||
* Lotus users, shown without it, so a media server's maintenance doesn't say
|
||||
* messages may be interrupted. Without it, every notice on the page shows.
|
||||
*
|
||||
* Kuma 2.x public JSON:
|
||||
* - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}],
|
||||
* maintenanceList (only windows UNDER maintenance right now), incidents
|
||||
@@ -19,7 +24,12 @@
|
||||
*/
|
||||
|
||||
export type StatusGroups = { homeserver?: string; calls?: string; login?: string };
|
||||
export type StatusPageConfig = { url: string; slug: string; groups: StatusGroups };
|
||||
export type StatusPageConfig = {
|
||||
url: string;
|
||||
slug: string;
|
||||
groups: StatusGroups;
|
||||
noticePrefix?: string;
|
||||
};
|
||||
|
||||
export type ServiceState = 'up' | 'down' | 'unknown';
|
||||
export type KumaMaintenance = { id: string; title: string; description: string; end?: number };
|
||||
@@ -69,9 +79,11 @@ export const resolveStatusPage = (
|
||||
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
|
||||
const g = obj(entry.groups) ?? {};
|
||||
const noticePrefix = str(entry.noticePrefix).trim();
|
||||
return {
|
||||
url: url.origin,
|
||||
slug,
|
||||
...(noticePrefix ? { noticePrefix } : {}),
|
||||
groups: {
|
||||
homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver,
|
||||
calls: str(g.calls) || DEFAULT_GROUPS.calls,
|
||||
@@ -133,10 +145,18 @@ const groupState = (
|
||||
return known > 0 ? 'up' : 'unknown';
|
||||
};
|
||||
|
||||
/** The title without the prefix, or undefined when it doesn't carry it. */
|
||||
const noticeTitle = (title: string, prefix: string | undefined): string | undefined => {
|
||||
if (!prefix) return title;
|
||||
if (title.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return undefined;
|
||||
return title.slice(prefix.length).trim() || undefined;
|
||||
};
|
||||
|
||||
export const parseKumaStatus = (
|
||||
pageJson: unknown,
|
||||
heartbeatJson: unknown,
|
||||
groupNames: StatusGroups,
|
||||
noticePrefix?: string,
|
||||
): ServerStatus => {
|
||||
const page = obj(pageJson) ?? {};
|
||||
const groups = new Map<string, string[]>();
|
||||
@@ -155,14 +175,14 @@ export const parseKumaStatus = (
|
||||
Array.isArray(page.maintenanceList) ? page.maintenanceList : []
|
||||
)
|
||||
.map((m) => obj(m))
|
||||
.filter((m): m is Record<string, unknown> => !!m && str(m.title) !== '')
|
||||
.filter((m): m is Record<string, unknown> => !!m && !!noticeTitle(str(m.title), noticePrefix))
|
||||
.filter((m) => !m.status || m.status === 'under-maintenance')
|
||||
.map((m) => {
|
||||
const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]);
|
||||
const end = Date.parse(str(slot?.endDate));
|
||||
return {
|
||||
id: String(m.id ?? str(m.title)),
|
||||
title: str(m.title),
|
||||
title: noticeTitle(str(m.title), noticePrefix) ?? '',
|
||||
description: str(m.description),
|
||||
...(Number.isFinite(end) ? { end } : {}),
|
||||
};
|
||||
@@ -170,10 +190,13 @@ export const parseKumaStatus = (
|
||||
|
||||
const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : [])
|
||||
.map((i) => obj(i))
|
||||
.filter((i): i is Record<string, unknown> => !!i && str(i.title) !== '' && i.active !== false)
|
||||
.filter(
|
||||
(i): i is Record<string, unknown> =>
|
||||
!!i && !!noticeTitle(str(i.title), noticePrefix) && i.active !== false,
|
||||
)
|
||||
.map((i) => ({
|
||||
id: String(i.id ?? str(i.title)),
|
||||
title: str(i.title),
|
||||
title: noticeTitle(str(i.title), noticePrefix) ?? '',
|
||||
content: str(i.content),
|
||||
style: str(i.style) || 'info',
|
||||
updated: str(i.lastUpdatedDate) || str(i.createdDate),
|
||||
|
||||
@@ -5,10 +5,10 @@ import { DENOISE_MODELS, ML_DENOISE_REQUIREMENTS, isMLDenoiseSupported } from '.
|
||||
|
||||
// ── Model catalog (data integrity) ──────────────────────────────────────────
|
||||
|
||||
test('DENOISE_MODELS lists the four models ordered best-quality (highest CPU) first', () => {
|
||||
test('DENOISE_MODELS lists the four models most-recommended first', () => {
|
||||
assert.deepEqual(
|
||||
DENOISE_MODELS.map((m) => m.id),
|
||||
['deepfilternet', 'dtln', 'rnnoise', 'speex'],
|
||||
['dtln', 'deepfilternet', 'rnnoise', 'speex'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* Detection utilities + model catalog for Lotus ML noise suppression
|
||||
* (DeepFilterNet 3 / DTLN / RNNoise / Speex). The catalog is ordered by
|
||||
* quality (and, correspondingly, CPU cost) — highest first — and drives the
|
||||
* order of the model dropdown in settings.
|
||||
* (DTLN / DeepFilterNet 3 / RNNoise / Speex). The catalog is ordered by
|
||||
* recommendation — most recommended first — and drives the order of the model
|
||||
* dropdown in settings.
|
||||
*/
|
||||
|
||||
import { DenoiseModelId } from '../state/settings';
|
||||
|
||||
// CPU = share of ONE core while in a call, measured with each model running
|
||||
// exactly as Lotus Call runs it (Ryzen 7 PRO 4750GE, Chromium); download = bytes
|
||||
// actually fetched on first use. Re-measure when a model or its settings change.
|
||||
// Ratings come from scoring each model against the clean voice (PESQ/STOI) on
|
||||
// real speech mixed with fan, keyboard, street and babble noise at SNR 5 and
|
||||
// 15 dB; `transients` is how well it removes typing and clicks.
|
||||
export type DenoiseModel = {
|
||||
id: DenoiseModelId;
|
||||
name: string;
|
||||
@@ -17,25 +23,25 @@ export type DenoiseModel = {
|
||||
voiceQuality: 'Moderate' | 'High' | 'Very High';
|
||||
};
|
||||
|
||||
// Ordered best-quality (highest CPU) first — this is the dropdown order.
|
||||
// Ordered most-recommended first — this is the dropdown order.
|
||||
export const DENOISE_MODELS: DenoiseModel[] = [
|
||||
{
|
||||
id: 'deepfilternet',
|
||||
name: 'DeepFilterNet 3 (beta)',
|
||||
id: 'dtln',
|
||||
name: 'DTLN (recommended)',
|
||||
description:
|
||||
'Studio-grade deep-learning model (48 kHz fullband, ONNX). Best quality; highest CPU and a larger one-time download.',
|
||||
cpuUsage: '25-50%',
|
||||
binarySize: '~18 MB',
|
||||
'Keeps voices the most natural of the four. Removes fans and typing well, but not traffic or people talking in the background.',
|
||||
cpuUsage: '~9%',
|
||||
binarySize: '~20 MB',
|
||||
transients: 'Excellent',
|
||||
voiceQuality: 'Very High',
|
||||
},
|
||||
{
|
||||
id: 'dtln',
|
||||
name: 'DTLN (beta)',
|
||||
id: 'deepfilternet',
|
||||
name: 'DeepFilterNet 3 (strongest)',
|
||||
description:
|
||||
'Dual-signal deep-learning model (16 kHz). Strong on transient noise; moderate CPU.',
|
||||
cpuUsage: '10-20%',
|
||||
binarySize: '~4 MB',
|
||||
'Removes the most noise, including loud typing and traffic, but voices can sound slightly muffled. Uses about three times the CPU of DTLN.',
|
||||
cpuUsage: '~30%',
|
||||
binarySize: '~18 MB',
|
||||
transients: 'Excellent',
|
||||
voiceQuality: 'High',
|
||||
},
|
||||
@@ -43,18 +49,18 @@ export const DENOISE_MODELS: DenoiseModel[] = [
|
||||
id: 'rnnoise',
|
||||
name: 'RNNoise',
|
||||
description:
|
||||
'Lightweight hybrid model (48 kHz). Very low CPU; good for steady noise like fans, but can sound processed at full strength.',
|
||||
cpuUsage: '< 5%',
|
||||
'Very light. Handles steady noise like fans and traffic; does little for typing and can sound slightly processed.',
|
||||
cpuUsage: '~3%',
|
||||
binarySize: '< 1 MB',
|
||||
transients: 'Good',
|
||||
transients: 'Poor',
|
||||
voiceQuality: 'Moderate',
|
||||
},
|
||||
{
|
||||
id: 'speex',
|
||||
name: 'Speex (Legacy)',
|
||||
name: 'Speex (legacy)',
|
||||
description:
|
||||
'Classic DSP noise suppressor. Minimal CPU, gentlest on voice; weakest suppression.',
|
||||
cpuUsage: '< 2%',
|
||||
'Classic suppressor kept for very slow computers. Barely reduces noise in our tests; prefer any other option.',
|
||||
cpuUsage: '~2%',
|
||||
binarySize: '< 1 MB',
|
||||
transients: 'Poor',
|
||||
voiceQuality: 'Moderate',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,7 +87,7 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
// the user (KeyUploadBanner) instead of retrying silently forever.
|
||||
fetchFn: watchKeyUploads((input, init) => globalThis.fetch(input, init)),
|
||||
timelineSupport: true,
|
||||
cryptoCallbacks: cryptoCallbacks as any,
|
||||
cryptoCallbacks,
|
||||
// SAS (emoji) + QR-code verification (show/scan/reciprocate).
|
||||
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
|
||||
oauthClientId: oidcIdentity?.oidc.clientId,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
const secretStorageKeys = new Map();
|
||||
|
||||
export function storePrivateKey(keyId, privateKey) {
|
||||
if (privateKey instanceof Uint8Array === false) {
|
||||
throw new Error('Unable to store, privateKey is invalid.');
|
||||
}
|
||||
secretStorageKeys.set(keyId, privateKey);
|
||||
}
|
||||
|
||||
function hasPrivateKey(keyId) {
|
||||
return secretStorageKeys.get(keyId) instanceof Uint8Array;
|
||||
}
|
||||
|
||||
function getPrivateKey(keyId) {
|
||||
return secretStorageKeys.get(keyId);
|
||||
}
|
||||
|
||||
export function clearSecretStorageKeys() {
|
||||
secretStorageKeys.clear();
|
||||
}
|
||||
|
||||
async function getSecretStorageKey({ keys }) {
|
||||
const keyIds = Object.keys(keys);
|
||||
const keyId = keyIds.find(hasPrivateKey);
|
||||
if (!keyId) return undefined;
|
||||
const privateKey = getPrivateKey(keyId);
|
||||
return [keyId, privateKey];
|
||||
}
|
||||
|
||||
function cacheSecretStorageKey(keyId, keyInfo, privateKey) {
|
||||
secretStorageKeys.set(keyId, privateKey);
|
||||
}
|
||||
|
||||
export const cryptoCallbacks = {
|
||||
getSecretStorageKey,
|
||||
cacheSecretStorageKey,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { clearSecretStorageKeys, cryptoCallbacks, storePrivateKey } from './secretStorageKeys';
|
||||
|
||||
const ask = (...ids: string[]) =>
|
||||
cryptoCallbacks.getSecretStorageKey!(
|
||||
{ keys: Object.fromEntries(ids.map((id) => [id, {} as never])) },
|
||||
'm.cross_signing.master',
|
||||
);
|
||||
|
||||
test('[#210] secret storage key cache: null when unknown, key when stored, copies input', async () => {
|
||||
clearSecretStorageKeys();
|
||||
assert.equal(await ask('k1'), null);
|
||||
|
||||
const key = new Uint8Array([1, 2, 3]);
|
||||
storePrivateKey('k1', key);
|
||||
key[0] = 9; // the cache keeps its own copy
|
||||
assert.deepEqual(await ask('k0', 'k1'), ['k1', new Uint8Array([1, 2, 3])]);
|
||||
|
||||
cryptoCallbacks.cacheSecretStorageKey!('k2', {} as never, new Uint8Array([7]));
|
||||
assert.deepEqual(await ask('k2'), ['k2', new Uint8Array([7])]);
|
||||
|
||||
clearSecretStorageKeys();
|
||||
assert.equal(await ask('k1', 'k2'), null);
|
||||
assert.throws(() => storePrivateKey('k3', 'nope' as never), /invalid/);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CryptoCallbacks } from 'matrix-js-sdk/lib/crypto-api';
|
||||
|
||||
const secretStorageKeys = new Map<string, Uint8Array<ArrayBuffer>>();
|
||||
|
||||
export function storePrivateKey(keyId: string, privateKey: Uint8Array) {
|
||||
if (privateKey instanceof Uint8Array === false) {
|
||||
throw new Error('Unable to store, privateKey is invalid.');
|
||||
}
|
||||
// Our own copy, on a plain ArrayBuffer (what the SDK's callback type wants).
|
||||
secretStorageKeys.set(keyId, new Uint8Array(privateKey));
|
||||
}
|
||||
|
||||
export function clearSecretStorageKeys() {
|
||||
secretStorageKeys.clear();
|
||||
}
|
||||
|
||||
export const cryptoCallbacks: CryptoCallbacks = {
|
||||
async getSecretStorageKey({ keys }) {
|
||||
const keyId = Object.keys(keys).find((id) => secretStorageKeys.get(id) instanceof Uint8Array);
|
||||
const privateKey = keyId ? secretStorageKeys.get(keyId) : undefined;
|
||||
// The SDK expects null (not undefined) when no key is cached.
|
||||
return keyId && privateKey ? [keyId, privateKey] : null;
|
||||
},
|
||||
cacheSecretStorageKey(keyId, keyInfo, privateKey) {
|
||||
secretStorageKeys.set(keyId, privateKey);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ export enum StateEvent {
|
||||
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
|
||||
// state-key, aggregated with parent-space packs like custom emoji.
|
||||
LotusSoundboardRoom = 'io.lotus.soundboard',
|
||||
// MSC2346 bridge info; state_key identifies the bridged channel. Bridges
|
||||
// still send the pre-stable uk.half-shot.bridge copy alongside it.
|
||||
Bridge = 'm.bridge',
|
||||
BridgeUnstable = 'uk.half-shot.bridge',
|
||||
}
|
||||
|
||||
export enum MessageEvent {
|
||||
|
||||
+42
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user