Compare commits
26
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 |
+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",
|
||||
|
||||
@@ -473,6 +473,37 @@ test.describe('local homeserver regression', () => {
|
||||
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,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();
|
||||
});
|
||||
});
|
||||
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(() => {
|
||||
|
||||
@@ -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';
|
||||
@@ -9,16 +9,28 @@ import { keyframes } from '@vanilla-extract/css';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Firework burst — the spark burst pops open from a point, drifts down a little
|
||||
* as it spreads (gravity) and fades fast. Scale + translate + opacity only. Long
|
||||
* gaps between bursts come from a low keyframe duty: invisible most of the cycle.
|
||||
* Firework burst — a thin spark ring expands from a pinpoint, brightens, then
|
||||
* fades as it grows. Scale + opacity only; the ring is a radial-gradient border
|
||||
* supplied inline. Long pauses between bursts come from a low keyframe-duty:
|
||||
* the ring spends most of the cycle collapsed and invisible.
|
||||
*/
|
||||
export const animBurst = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(0.15)', opacity: '0' },
|
||||
'3%': { transform: 'translate3d(0, 0, 0) scale(0.4)', opacity: '1' },
|
||||
'14%': { transform: 'translate3d(0, 6px, 0) scale(1)', opacity: '0.75' },
|
||||
'24%': { transform: 'translate3d(0, 14px, 0) scale(1.1)', opacity: '0' },
|
||||
'100%': { transform: 'translate3d(0, 14px, 0) scale(1.1)', opacity: '0' },
|
||||
'0%': { transform: 'scale(0.05)', opacity: '0' },
|
||||
'4%': { transform: 'scale(0.12)', opacity: '0.95' },
|
||||
'22%': { transform: 'scale(1)', opacity: '0.55' },
|
||||
'34%': { transform: 'scale(1.25)', opacity: '0' },
|
||||
'100%': { transform: 'scale(1.25)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Burst core flash — the bright pinpoint at a firework's origin pops just before
|
||||
* the ring blooms, then quickly dims. Pairs with animBurst on the same cadence.
|
||||
*/
|
||||
export const animCoreFlash = keyframes({
|
||||
'0%': { transform: 'scale(0.2)', opacity: '0' },
|
||||
'3%': { transform: 'scale(1)', opacity: '1' },
|
||||
'14%': { transform: 'scale(0.6)', opacity: '0' },
|
||||
'100%': { transform: 'scale(0.6)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animBurst,
|
||||
animCoreFlash,
|
||||
animShimmer,
|
||||
animConfettiFall,
|
||||
animConfettiSway,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
|
||||
/**
|
||||
* New Year overlay — a midnight celebration. Layered oklch gradients sink the
|
||||
* app into a deep navy night; fireworks pop as spark bursts at the edges, a
|
||||
* app into a deep navy night; fireworks bloom as expanding spark rings, a
|
||||
* champagne-gold shimmer sweeps across, confetti slivers tumble down, and
|
||||
* sparkle stars twinkle. All motion is transform/opacity only.
|
||||
*
|
||||
@@ -25,8 +26,8 @@ import {
|
||||
* position:fixed, or pointer-events here — kept well below opaque so chat text
|
||||
* stays WCAG-AA legible.
|
||||
*
|
||||
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (two still
|
||||
* bursts peeking in from the top corners, scattered confetti, a still shimmer) with
|
||||
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a frozen
|
||||
* firework bloom mid-burst, scattered gold confetti, a still shimmer band) with
|
||||
* no `animation` at all. The settings preview always passes reduced=true.
|
||||
*/
|
||||
|
||||
@@ -88,44 +89,16 @@ const sparkleUri = (color: string) =>
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 L14 10 L24 12 L14 14 L12 24 L10 14 L0 12 L10 10 Z' fill='${color}'/></svg>`,
|
||||
)}")`;
|
||||
|
||||
// [Gitea #230] A firework is spark rays + ember dots radiating from a point
|
||||
// (not a solid ring), drawn once as an SVG and scaled/faded by the keyframes.
|
||||
const burstUri = (ring: string, core: string) => {
|
||||
const rays = Array.from({ length: 14 }, (_, k) => {
|
||||
const a = (k / 14) * Math.PI * 2 + (k % 2) * 0.12;
|
||||
const [c, si] = [Math.cos(a), Math.sin(a)];
|
||||
const r1 = k % 2 ? 18 : 14;
|
||||
const r2 = k % 2 ? 38 : 44;
|
||||
const p = (r: number) => `${(50 + c * r).toFixed(1)} ${(50 + si * r).toFixed(1)}`;
|
||||
return (
|
||||
`<path d='M${p(r1)} L${p(r2)}' stroke='${ring}' stroke-width='1.6' stroke-linecap='round' stroke-opacity='0.85'/>` +
|
||||
`<circle cx='${(50 + c * (r2 + 4)).toFixed(1)}' cy='${(50 + si * (r2 + 4)).toFixed(1)}' r='1.5' fill='${core}'/>`
|
||||
);
|
||||
}).join('');
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='6' fill='${core}' opacity='0.5'/>${rays}</svg>`,
|
||||
)}")`;
|
||||
};
|
||||
|
||||
export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
const bursts = useMemo<Burst[]>(
|
||||
() =>
|
||||
// [Gitea #230] Bursts go off in the top band or along the side edges,
|
||||
// away from the reading column (top ones centred at the very top edge so they
|
||||
// fan down through the header row and fade), and are kept small; the old ones were
|
||||
// large rings anywhere in the upper two-thirds, over message text.
|
||||
// Bursts cluster in the upper two-thirds of the sky, away from typical text.
|
||||
Array.from({ length: 7 }, (_, i) => {
|
||||
const hue = BURST_HUES[i % BURST_HUES.length];
|
||||
const edge = i % 3 === 2;
|
||||
const right = rand(i + 51) > 0.5;
|
||||
return {
|
||||
top: edge ? 22 + rand(i + 1) * 45 : -2 + rand(i + 1) * 5,
|
||||
left: edge
|
||||
? right
|
||||
? 92 + rand(i + 11) * 6
|
||||
: 2 + rand(i + 11) * 6
|
||||
: 6 + rand(i + 11) * 88,
|
||||
size: 90 + Math.floor(rand(i + 21) * 60),
|
||||
top: 8 + rand(i + 1) * 48,
|
||||
left: 8 + rand(i + 11) * 84,
|
||||
size: 130 + Math.floor(rand(i + 21) * 110),
|
||||
ring: hue[0],
|
||||
core: hue[1],
|
||||
duration: 6.5 + rand(i + 31) * 4,
|
||||
@@ -201,16 +174,9 @@ export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Fireworks — spark bursts that pop, drift down a little and fade. In
|
||||
reduced mode, two still bursts peek in from the top corners instead
|
||||
of one frozen over the timeline. */}
|
||||
{(reduced
|
||||
? [
|
||||
{ ...bursts[0], top: -4, left: 1, size: 160 },
|
||||
{ ...bursts[1], top: -3, left: 99, size: 130 },
|
||||
]
|
||||
: bursts
|
||||
).map((b, i) => (
|
||||
{/* Fireworks — expanding spark rings + a core flash. In reduced mode we
|
||||
freeze the first burst mid-bloom and drop the rest. */}
|
||||
{(reduced ? bursts.slice(0, 1) : bursts).map((b, i) => (
|
||||
<div
|
||||
key={`b${i}`}
|
||||
aria-hidden="true"
|
||||
@@ -222,15 +188,57 @@ export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
height: `${b.size}px`,
|
||||
marginLeft: `${-b.size / 2}px`,
|
||||
marginTop: `${-b.size / 2}px`,
|
||||
backgroundImage: burstUri(b.ring, b.core),
|
||||
backgroundSize: '100% 100%',
|
||||
opacity: reduced ? 0.35 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{/* Spark ring */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '50%',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
background: `radial-gradient(circle, transparent 56%, ${b.ring} 64%, transparent 74%)`,
|
||||
transform: reduced ? 'scale(0.82)' : undefined,
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
{/* Inner secondary ring for a fuller bloom */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '18%',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, transparent 50%, ${b.ring} 60%, transparent 72%)`,
|
||||
transform: reduced ? 'scale(0.7)' : undefined,
|
||||
opacity: reduced ? 0.4 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBurst} ${b.duration}s ease-out ${b.delay + 0.12}s infinite`,
|
||||
}}
|
||||
/>
|
||||
{/* Core flash */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: '14%',
|
||||
height: '14%',
|
||||
marginLeft: '-7%',
|
||||
marginTop: '-7%',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${b.core} 0%, transparent 70%)`,
|
||||
transform: reduced ? 'scale(0.9)' : undefined,
|
||||
opacity: reduced ? 0.85 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animCoreFlash} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Twinkling sparkle stars. */}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2037,7 +2037,7 @@ export function Calls() {
|
||||
<Text size="L400">Model</Text>
|
||||
<SettingTile
|
||||
title="ML model"
|
||||
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
|
||||
description="Choose the machine learning model used for noise removal. DTLN is the best all-rounder; DeepFilterNet removes the most noise at a higher CPU cost. None of the models remove other people talking in the background. CPU figures are the share of one processor core during a call on a mid-range desktop; Task Manager shows that divided by your number of cores, and slower computers use more."
|
||||
after={
|
||||
<SettingsSelect<DenoiseModelId>
|
||||
value={callDenoiseModel}
|
||||
@@ -2064,9 +2064,9 @@ export function Calls() {
|
||||
<Box direction="Row" gap="400" wrap="Wrap">
|
||||
{(
|
||||
[
|
||||
{ label: 'CPU', value: selectedDenoiseModel.cpuUsage },
|
||||
{ label: 'CPU (one core)', value: selectedDenoiseModel.cpuUsage },
|
||||
{ label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
|
||||
{ label: 'Transients', value: selectedDenoiseModel.transients },
|
||||
{ label: 'Typing & clicks', value: selectedDenoiseModel.transients },
|
||||
{ label: 'Download', value: selectedDenoiseModel.binarySize },
|
||||
] as const
|
||||
).map((stat) => (
|
||||
@@ -2113,7 +2113,7 @@ export function Calls() {
|
||||
</Box>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" priority="300">
|
||||
Transients
|
||||
Typing & clicks
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -2137,7 +2137,7 @@ export function Calls() {
|
||||
</details>
|
||||
|
||||
<Text size="T200" priority="400">
|
||||
Note: Applying changes requires rejoining the call.
|
||||
Changes apply straight away, even during a call.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -212,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