Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8a04fee20 | ||
|
|
843a48dd10 | ||
|
|
6d86d25b7f | ||
|
|
56af9520bd | ||
|
|
c247024bcd | ||
|
|
29ece6a0c8 | ||
|
|
f7f6059b33 | ||
|
|
71b7619082 | ||
|
|
e4591f669b | ||
|
|
185970bc5a | ||
|
|
d5b0b40b17 | ||
|
|
8cdf192b56 | ||
|
|
222927b558 | ||
|
|
02bd356a89 | ||
|
|
22d5aea5e6 |
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { Box, Button, Text } from 'folds';
|
||||
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
|
||||
import { getUIAFlowForStages } from '../utils/matrix-uia';
|
||||
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
|
||||
@@ -23,7 +24,18 @@ type ActionUIAProps = {
|
||||
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
|
||||
const mx = useMatrixClient();
|
||||
const completed = useUIACompleted(authData);
|
||||
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
|
||||
// The server may offer several ways to confirm (e.g. password or SSO). Start
|
||||
// with the given one but let the user switch: an account that signs in with
|
||||
// SSO may have no password it knows.
|
||||
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
|
||||
const [flow, setFlow] = useState<UIAFlow>(ongoingFlow);
|
||||
const { getStageToComplete } = useUIAFlow(authData, flow);
|
||||
const other = supportedFlows.find((f) => f.stages.join() !== flow.stages.join());
|
||||
const otherLabel = other?.stages.includes(AuthType.Sso)
|
||||
? 'Use SSO instead'
|
||||
: other?.stages.includes(AuthType.Password)
|
||||
? 'Use your password instead'
|
||||
: undefined;
|
||||
|
||||
const stageToComplete = getStageToComplete();
|
||||
|
||||
@@ -31,7 +43,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
||||
return (
|
||||
<UIAFlowOverlay
|
||||
currentStep={completed.length + 1}
|
||||
stepCount={ongoingFlow.stages.length}
|
||||
stepCount={flow.stages.length}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
{stageToComplete.type === AuthType.Password && (
|
||||
@@ -50,6 +62,21 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
||||
submitAuthDict={action}
|
||||
/>
|
||||
)}
|
||||
{other && otherLabel && completed.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ marginTop: 8 }}>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => setFlow(other)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{otherLabel}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</UIAFlowOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||
import { whenMediaAuthReady } from '../../../../sw-session';
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
@@ -115,13 +116,24 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
const handleLoad = () => {
|
||||
setLoad(true);
|
||||
};
|
||||
// An image requested before the service worker controls the page goes out
|
||||
// without the access token and fails (first visit, just after an update).
|
||||
// Retry once by itself when that's ready; only then offer "Retry".
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const autoRetried = useRef(false);
|
||||
const handleError = () => {
|
||||
setLoad(false);
|
||||
if (!autoRetried.current && !encInfo) {
|
||||
autoRetried.current = true;
|
||||
whenMediaAuthReady().then(() => setAttempt((n) => n + 1));
|
||||
return;
|
||||
}
|
||||
setError(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(false);
|
||||
setAttempt((n) => n + 1);
|
||||
loadSrc();
|
||||
};
|
||||
|
||||
@@ -182,7 +194,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
<Box key={attempt} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderImage({
|
||||
alt: body,
|
||||
title: body,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
color,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
@@ -43,6 +44,8 @@ import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { SettingsPages } from './pages';
|
||||
import { SettingsSearch } from './search/SettingsSearch';
|
||||
import type { SettingsEntry } from './search/settingsIndex';
|
||||
|
||||
export { SettingsPages };
|
||||
|
||||
@@ -94,6 +97,49 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
|
||||
});
|
||||
const menuItems = useSettingsMenuItems();
|
||||
// [Gitea #463] Settings search: results replace the page list while typing;
|
||||
// picking one opens its page and briefly highlights the setting.
|
||||
const pageNames = useMemo(
|
||||
() => new Map(menuItems.map((item) => [item.page, item.name])),
|
||||
[menuItems],
|
||||
);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [highlight, setHighlight] = useState<string>();
|
||||
const pickResult = useCallback((entry: SettingsEntry) => {
|
||||
setActivePage(entry.page);
|
||||
setHighlight(entry.title);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!highlight) return undefined;
|
||||
let tries = 0;
|
||||
let flashTimer: number | undefined;
|
||||
const find = () => {
|
||||
const match = [...document.querySelectorAll<HTMLElement>('p, span, label, h1, h2, h3')].find(
|
||||
(el) =>
|
||||
!el.closest('nav') && el.textContent?.trim() === highlight && el.offsetParent !== null,
|
||||
);
|
||||
if (!match) {
|
||||
tries += 1;
|
||||
if (tries < 20) poll = window.setTimeout(find, 100);
|
||||
else setHighlight(undefined);
|
||||
return;
|
||||
}
|
||||
const target = (match.closest('[class*="SequenceCard"]') as HTMLElement | null) ?? match;
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
const prev = target.style.boxShadow;
|
||||
target.style.boxShadow = `0 0 0 2px ${color.Primary.Main}`;
|
||||
target.style.transition = 'box-shadow 0.3s';
|
||||
flashTimer = window.setTimeout(() => {
|
||||
target.style.boxShadow = prev;
|
||||
setHighlight(undefined);
|
||||
}, 1800);
|
||||
};
|
||||
let poll = window.setTimeout(find, 50);
|
||||
return () => {
|
||||
window.clearTimeout(poll);
|
||||
window.clearTimeout(flashTimer);
|
||||
};
|
||||
}, [highlight, activePage]);
|
||||
|
||||
const handlePageRequestClose = () => {
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
@@ -134,8 +180,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<SettingsSearch
|
||||
pageNames={pageNames}
|
||||
onActiveChange={setSearching}
|
||||
onPick={pickResult}
|
||||
/>
|
||||
<PageNavContent>
|
||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
|
||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }} hidden={searching}>
|
||||
{menuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
|
||||
@@ -38,11 +38,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||
import {
|
||||
describePresenceError,
|
||||
setPresenceWithRetry,
|
||||
writePresence,
|
||||
} from '../../../utils/presenceWrite';
|
||||
import { describePresenceError, queuePresenceWrite } from '../../../utils/presenceWrite';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
@@ -419,6 +415,10 @@ function ProfileStatus() {
|
||||
// until our own echo lands or a short window elapses (bounded so a genuinely
|
||||
// dropped echo can't block real cross-device updates forever).
|
||||
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
|
||||
// The status as this device last applied or synced it. "Save" compares with
|
||||
// this, not with the server echo, so it switches off the moment a status is
|
||||
// applied instead of staying lit until the (rate-limited) write lands.
|
||||
const [savedMsg, setSavedMsg] = useState<string>(initialStatus);
|
||||
const [clearAfter, setClearAfter] = useState('0');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
const { presets, addPreset, removePreset } = useStatusPresets();
|
||||
@@ -442,7 +442,7 @@ function ProfileStatus() {
|
||||
if (pending) {
|
||||
if (remoteStatus === pending.value) {
|
||||
pendingAppliedRef.current = null; // our own echo landed — accept it
|
||||
} else if (Date.now() - pending.ts < 15_000) {
|
||||
} else if (Date.now() - pending.ts < 30_000) {
|
||||
return; // stale echo of the previous status; ignore within the window
|
||||
} else {
|
||||
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
||||
@@ -451,6 +451,7 @@ function ProfileStatus() {
|
||||
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
|
||||
// typed: don't show it in the status field or keep it as their status.
|
||||
if (remoteStatus === DND_STATUS_SENTINEL) return;
|
||||
setSavedMsg(remoteStatus);
|
||||
if (remoteStatus) {
|
||||
setStatusMsg(remoteStatus);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||
@@ -467,9 +468,10 @@ function ProfileStatus() {
|
||||
useCallback(
|
||||
(msg: string) =>
|
||||
// Synapse allows ONE presence write per 10 s per user (shared by all
|
||||
// devices) and our heartbeat spends that budget too — wait out a 429
|
||||
// instead of failing the user's save.
|
||||
setPresenceWithRetry(mx, {
|
||||
// devices) and our heartbeat spends that budget too. The shared queue
|
||||
// waits out a 429 and folds quick successive changes into one write;
|
||||
// the UI doesn't wait on it (the status is applied locally at once).
|
||||
queuePresenceWrite(mx, {
|
||||
// Derive presence from the user's chosen setting so writing a status
|
||||
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
@@ -498,6 +500,7 @@ function ProfileStatus() {
|
||||
(rawMsg: string, clearAfterValue: string) => {
|
||||
statusDirtyRef.current = false;
|
||||
const msg = rawMsg.trim();
|
||||
setSavedMsg(msg);
|
||||
// Guard against a stale presence echo reverting this value (see the sync effect).
|
||||
pendingAppliedRef.current = { value: msg, ts: Date.now() };
|
||||
noteLocalStatusWrite();
|
||||
@@ -527,19 +530,17 @@ function ProfileStatus() {
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
applyStatus(statusMsg, clearAfter);
|
||||
};
|
||||
|
||||
// Preset click = one-click apply: reflect it in the inputs and save immediately.
|
||||
const applyPreset = useCallback(
|
||||
(preset: StatusPreset) => {
|
||||
if (saving) return;
|
||||
setStatusMsg(preset.label);
|
||||
setClearAfter(preset.clearAfter);
|
||||
applyStatus(preset.label, preset.clearAfter);
|
||||
},
|
||||
[saving, applyStatus],
|
||||
[applyStatus],
|
||||
);
|
||||
|
||||
const handleSaveCurrent = useCallback(() => {
|
||||
@@ -560,17 +561,18 @@ function ProfileStatus() {
|
||||
pendingAppliedRef.current = { value: '', ts: Date.now() };
|
||||
noteLocalStatusWrite();
|
||||
setStatusMsg('');
|
||||
setSavedMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
// Preserve the user's chosen presence when clearing the status message.
|
||||
writePresence(mx, {
|
||||
queuePresenceWrite(mx, {
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
status_msg: '',
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const hasChanges = statusMsg !== (presence?.status ?? '');
|
||||
const hasChanges = statusMsg.trim() !== savedMsg;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
@@ -599,7 +601,6 @@ function ProfileStatus() {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -625,7 +626,6 @@ function ProfileStatus() {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -651,7 +651,7 @@ function ProfileStatus() {
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
outlined
|
||||
disabled={saving || !statusMsg.trim()}
|
||||
disabled={!statusMsg.trim()}
|
||||
onClick={handleSaveCurrent}
|
||||
before={<Icon size="100" src={Icons.Plus} />}
|
||||
>
|
||||
@@ -662,7 +662,7 @@ function ProfileStatus() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Input
|
||||
name="statusMsgInput"
|
||||
@@ -672,7 +672,6 @@ function ProfileStatus() {
|
||||
placeholder="What's on your mind?"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={saving}
|
||||
maxLength={64}
|
||||
/>
|
||||
<Text
|
||||
@@ -733,13 +732,17 @@ function ProfileStatus() {
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || saving}
|
||||
disabled={!hasChanges}
|
||||
type="submit"
|
||||
>
|
||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saving && (
|
||||
<Text size="T200" priority="300" role="status">
|
||||
Status set. Sharing it with others, this can take a few seconds.
|
||||
</Text>
|
||||
)}
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{describePresenceError(saveState.error)}
|
||||
@@ -764,7 +767,6 @@ function ProfileStatus() {
|
||||
radii="300"
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text size="B300">Clear Status</Text>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Input, MenuItem, Text, config } from 'folds';
|
||||
import { SettingsPages } from '../pages';
|
||||
import { searchSettings, SettingsEntry } from './settingsIndex';
|
||||
import { loadSettingsIndex } from './loadSettingsIndex';
|
||||
import { isTauri } from '../../../hooks/useTauri';
|
||||
|
||||
type SettingsSearchProps = {
|
||||
pageNames: Map<SettingsPages, string>;
|
||||
/** Whether a query is active (the page list is hidden meanwhile). */
|
||||
onActiveChange: (active: boolean) => void;
|
||||
onPick: (entry: SettingsEntry) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #463] Search box at the top of the settings sidebar. Ctrl/Cmd+F
|
||||
* focuses it while settings are open; Esc clears it.
|
||||
*/
|
||||
export function SettingsSearch({ pageNames, onActiveChange, onPick }: SettingsSearchProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [entries, setEntries] = useState<SettingsEntry[]>();
|
||||
const active = query.trim().length > 0;
|
||||
|
||||
useEffect(() => onActiveChange(active), [active, onActiveChange]);
|
||||
|
||||
const ensureIndex = () => {
|
||||
if (!entries) loadSettingsIndex().then(setEntries, () => setEntries([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
|
||||
// Capture phase + stop: settings is on top, so this beats the app's
|
||||
// own Ctrl/Cmd+F (message search) underneath.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, []);
|
||||
|
||||
// Only desktop-app options in the desktop app; only pages that exist here.
|
||||
const results = entries
|
||||
? searchSettings(entries, query, { desktop: isTauri() }).filter((e) => pageNames.has(e.page))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="400"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
placeholder="Search settings"
|
||||
aria-label="Search settings"
|
||||
value={query}
|
||||
onFocus={ensureIndex}
|
||||
onChange={(e) => {
|
||||
ensureIndex();
|
||||
setQuery(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && query) {
|
||||
e.stopPropagation();
|
||||
setQuery('');
|
||||
}
|
||||
if (e.key === 'Enter' && results[0]) {
|
||||
onPick(results[0]);
|
||||
setQuery('');
|
||||
}
|
||||
}}
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
after={
|
||||
query ? (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setQuery('')}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{active && (
|
||||
<Box direction="Column" role="list" aria-label="Matching settings">
|
||||
{entries === undefined && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
Searching…
|
||||
</Text>
|
||||
)}
|
||||
{entries !== undefined && results.length === 0 && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
No settings match “{query.trim()}”.
|
||||
</Text>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<MenuItem
|
||||
key={`${r.page}-${r.title}`}
|
||||
role="listitem"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
onClick={() => {
|
||||
onPick(r);
|
||||
setQuery('');
|
||||
}}
|
||||
>
|
||||
<Box direction="Column" style={{ padding: `${config.space.S100} 0`, minWidth: 0 }}>
|
||||
<Text size="T300" truncate>
|
||||
{r.title}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{pageNames.get(r.page)}
|
||||
{r.section && r.section !== r.title ? ` · ${r.section}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { parseSettingsSource, SettingsEntry } from './settingsIndex';
|
||||
|
||||
// [Gitea #463] The settings source, as text, in its own lazily loaded chunk:
|
||||
// fetched the first time someone searches, never at startup.
|
||||
const sources = import.meta.glob(['../**/*.tsx', '!../**/*.test.tsx'], {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
}) as Record<string, () => Promise<string>>;
|
||||
|
||||
let index: Promise<SettingsEntry[]> | undefined;
|
||||
|
||||
export const loadSettingsIndex = (): Promise<SettingsEntry[]> => {
|
||||
index ??= Promise.all(
|
||||
Object.entries(sources).map(async ([path, load]) =>
|
||||
// '../general/General.tsx' -> '/general/General.tsx' (what the parser matches)
|
||||
parseSettingsSource(path.replace(/^\.\./, ''), await load()),
|
||||
),
|
||||
).then((all) => all.flat());
|
||||
return index;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { parseSettingsSource, searchSettings } from './settingsIndex';
|
||||
import { SettingsPages } from '../pages';
|
||||
|
||||
const general = fs.readFileSync('src/app/features/settings/general/General.tsx', 'utf8');
|
||||
const devices = fs.readFileSync('src/app/features/settings/devices/Devices.tsx', 'utf8');
|
||||
const sys = fs.readFileSync(
|
||||
'src/app/features/settings/notifications/SystemNotification.tsx',
|
||||
'utf8',
|
||||
);
|
||||
const entries = [
|
||||
...parseSettingsSource('src/app/features/settings/general/General.tsx', general),
|
||||
...parseSettingsSource('src/app/features/settings/devices/Devices.tsx', devices),
|
||||
...parseSettingsSource('src/app/features/settings/notifications/SystemNotification.tsx', sys),
|
||||
];
|
||||
const find = (t: string) => entries.find((e) => e.title === t);
|
||||
|
||||
test('[#463] indexes the real settings source with the right pages', () => {
|
||||
assert.equal(find('Push to deafen')?.page, SettingsPages.CallsPage);
|
||||
assert.equal(find('Strip tracking parameters from links')?.page, SettingsPages.DevicesPage);
|
||||
assert.equal(find('Page zoom')?.page, SettingsPages.AppearancePage);
|
||||
assert.equal(find('Enter for a new line')?.page, SettingsPages.MessagesPage);
|
||||
assert.equal(find('Show hidden events')?.page, SettingsPages.DeveloperToolsPage);
|
||||
assert.equal(find('Device verification')?.page, SettingsPages.DevicesPage);
|
||||
assert.equal(find('Quiet hours')?.page, SettingsPages.NotificationPage);
|
||||
assert.equal(find('Launch on login')?.desktopOnly, true);
|
||||
assert.match(find('Strip tracking parameters from links')?.description ?? '', /utm_/);
|
||||
assert.ok(entries.length > 70, `indexed ${entries.length}`);
|
||||
});
|
||||
|
||||
test('[#463] search: words anywhere, title first, desktop-only hidden in a browser', () => {
|
||||
assert.equal(searchSettings(entries, 'deafen')[0]?.title, 'Push to deafen');
|
||||
assert.equal(
|
||||
searchSettings(entries, 'tracking')[0]?.title,
|
||||
'Strip tracking parameters from links',
|
||||
);
|
||||
assert.equal(searchSettings(entries, 'zoom')[0]?.title, 'Page zoom');
|
||||
assert.ok(searchSettings(entries, 'utm').some((e) => e.title.startsWith('Strip tracking')));
|
||||
assert.equal(searchSettings(entries, 'launch on login').length, 0);
|
||||
assert.equal(
|
||||
searchSettings(entries, 'launch on login', { desktop: true })[0]?.title,
|
||||
'Launch on login',
|
||||
);
|
||||
assert.deepEqual(searchSettings(entries, ' '), []);
|
||||
});
|
||||
|
||||
test('[#463] parser handles the attribute forms and skips dynamic titles', () => {
|
||||
const src = `
|
||||
<Text size="L400">Things</Text>
|
||||
<SettingTile title="Plain" description="A & 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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,3 +9,25 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?:
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once media requests can carry the session: immediately-ish when a
|
||||
* service worker already controls the page, otherwise when one takes control
|
||||
* (first visit / just updated) or after `timeoutMs`. Used to retry a media
|
||||
* load that raced the worker's activation and went out without a token.
|
||||
*/
|
||||
export function whenMediaAuthReady(timeoutMs = 3000): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const sw = 'serviceWorker' in navigator ? navigator.serviceWorker : undefined;
|
||||
if (!sw || sw.controller) {
|
||||
window.setTimeout(resolve, 500);
|
||||
return;
|
||||
}
|
||||
const done = () => {
|
||||
sw.removeEventListener('controllerchange', done);
|
||||
resolve();
|
||||
};
|
||||
sw.addEventListener('controllerchange', done);
|
||||
window.setTimeout(done, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
+42
-1
@@ -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