Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b0cf9c47 | ||
|
|
1b561ca1ca | ||
|
|
53cceaa66e | ||
|
|
8794465034 | ||
|
|
ace084c91c | ||
|
|
c73a3f8b31 | ||
|
|
504899aa14 | ||
|
|
7d31b2b2f7 | ||
|
|
61d2433721 | ||
|
|
78b19f1ced | ||
|
|
2f5bcf2b91 | ||
|
|
d1a9625b1c | ||
|
|
9ab01ff6f6 | ||
|
|
d2c3c56b18 | ||
|
|
e2ce177930 | ||
|
|
1ebc42863e | ||
|
|
58f7b18746 | ||
|
|
43445db5cc | ||
|
|
3c33169297 | ||
|
|
3163829c57 | ||
|
|
2264c79833 | ||
|
|
2612f71c57 | ||
|
|
e8a04fee20 | ||
|
|
843a48dd10 | ||
|
|
6d86d25b7f | ||
|
|
56af9520bd |
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"statusPages": {
|
"statusPages": {
|
||||||
"matrix.lotusguild.org": {
|
"matrix.lotusguild.org": {
|
||||||
"url": "https://isitup.lotusguild.org",
|
"url": "https://isitup.lotusguild.org",
|
||||||
"slug": "matrix",
|
"slug": "public",
|
||||||
|
"noticePrefix": "Matrix:",
|
||||||
"groups": {
|
"groups": {
|
||||||
"homeserver": "Homeserver",
|
"homeserver": "Homeserver",
|
||||||
"calls": "Voice calls",
|
"calls": "Voice calls",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
"@lotusguild/element-call-embedded": "0.28.0-lotus.5",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.28.0-lotus.2",
|
"version": "0.28.0-lotus.5",
|
||||||
"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",
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.5/element-call-embedded-0.28.0-lotus.5.tgz",
|
||||||
"integrity": "sha512-g3xaZ/i3RQ0mToWmjLi08HjSuKD3BpAllj9aEucPS8wh3Uwmz4HXa0tgF1hEN3dPrZR2BTWLQii5Mtfh4037vg==",
|
"integrity": "sha512-yW95xjtlUv65B7JCd0dxOv2M8qdjXvMvlk4btDmobr4D+kIFWtfyOCranmzHz9KbaS3vwy3WgTgp73G8mAAO/Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
"@lotusguild/element-call-embedded": "0.28.0-lotus.5",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { SrOnly } from '../features/call-status/styles.css';
|
|||||||
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||||
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||||
import { useCallQuality } from '../hooks/useCallQuality';
|
import { useCallQuality } from '../hooks/useCallQuality';
|
||||||
|
import { useCallDenoise } from '../hooks/useCallDenoise';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
@@ -836,6 +837,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useMutedTalkWarning(embed, joined);
|
useMutedTalkWarning(embed, joined);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
useCallQuality(embed);
|
useCallQuality(embed);
|
||||||
|
useCallDenoise(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
embed,
|
embed,
|
||||||
useCallback(() => {
|
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';
|
||||||
@@ -27,6 +27,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useLocalTime } from '../../hooks/useLocalTime';
|
import { useLocalTime } from '../../hooks/useLocalTime';
|
||||||
|
import { UserBridgeBadge } from '../bridge';
|
||||||
|
|
||||||
type UserHeroProps = {
|
type UserHeroProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -143,6 +144,7 @@ export function UserHeroName({
|
|||||||
>
|
>
|
||||||
{displayName ?? username ?? userId}
|
{displayName ?? username ?? userId}
|
||||||
</Text>
|
</Text>
|
||||||
|
<UserBridgeBadge userId={userId} size="200" />
|
||||||
</Box>
|
</Box>
|
||||||
{pronouns && (
|
{pronouns && (
|
||||||
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
<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 { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||||
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
||||||
|
import { UserBridgeBadge } from '../../components/bridge';
|
||||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||||
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
||||||
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||||
@@ -178,6 +179,7 @@ function MemberItem({
|
|||||||
{presence && (
|
{presence && (
|
||||||
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
|
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
|
||||||
)}
|
)}
|
||||||
|
<UserBridgeBadge userId={member.userId} />
|
||||||
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
|
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
|
||||||
{typing && (
|
{typing && (
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<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 { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
|
import { RoomBridgeChip } from '../../components/bridge';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||||
@@ -690,6 +691,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
<RoomBridgeChip room={room} />
|
||||||
</Box>
|
</Box>
|
||||||
{topic && (
|
{topic && (
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|||||||
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||||
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
||||||
import { PowerIcon } from '../../../components/power';
|
import { PowerIcon } from '../../../components/power';
|
||||||
|
import { UserBridgeBadge } from '../../../components/bridge';
|
||||||
import colorMXID from '../../../../util/colorMXID';
|
import colorMXID from '../../../../util/colorMXID';
|
||||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||||
@@ -1084,6 +1085,7 @@ export const Message = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
|
<UserBridgeBadge userId={senderId} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="100">
|
<Box shrink="No" gap="100">
|
||||||
{messageLayout === MessageLayout.Modern && hover && (
|
{messageLayout === MessageLayout.Modern && hover && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DenoiseNode,
|
DenoiseNode,
|
||||||
buildGateNode,
|
buildGateNode,
|
||||||
buildModelNode,
|
buildModelNode,
|
||||||
|
monoDownmix,
|
||||||
readDb,
|
readDb,
|
||||||
sampleRateFor,
|
sampleRateFor,
|
||||||
} from '../../../utils/denoisePipeline';
|
} from '../../../utils/denoisePipeline';
|
||||||
@@ -139,7 +140,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
inAnalyser.fftSize = 1024;
|
inAnalyser.fftSize = 1024;
|
||||||
source.connect(inAnalyser);
|
source.connect(inAnalyser);
|
||||||
|
|
||||||
let head: AudioNode = source;
|
let head: AudioNode = source.connect(monoDownmix(ctx));
|
||||||
let gate: AudioWorkletNode | null = null;
|
let gate: AudioWorkletNode | null = null;
|
||||||
if (useGate) {
|
if (useGate) {
|
||||||
gate = await buildGateNode(ctx, gateThreshold);
|
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 ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
||||||
const source = ctx.createBufferSource();
|
const source = ctx.createBufferSource();
|
||||||
source.buffer = clip;
|
source.buffer = clip;
|
||||||
|
const mono = source.connect(monoDownmix(ctx));
|
||||||
let playGate: AudioWorkletNode | null = null;
|
let playGate: AudioWorkletNode | null = null;
|
||||||
let playModelNode: DenoiseNode | null = null;
|
let playModelNode: DenoiseNode | null = null;
|
||||||
if (playModel) {
|
if (playModel) {
|
||||||
let head: AudioNode = source;
|
let head: AudioNode = mono;
|
||||||
if (useGate) {
|
if (useGate) {
|
||||||
playGate = await buildGateNode(ctx, gateThreshold);
|
playGate = await buildGateNode(ctx, gateThreshold);
|
||||||
head.connect(playGate);
|
head.connect(playGate);
|
||||||
@@ -321,7 +323,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
head.connect(playModelNode.node);
|
head.connect(playModelNode.node);
|
||||||
playModelNode.node.connect(ctx.destination);
|
playModelNode.node.connect(ctx.destination);
|
||||||
} else {
|
} else {
|
||||||
source.connect(ctx.destination);
|
mono.connect(ctx.destination);
|
||||||
}
|
}
|
||||||
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
||||||
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
||||||
|
|||||||
@@ -2037,7 +2037,7 @@ export function Calls() {
|
|||||||
<Text size="L400">Model</Text>
|
<Text size="L400">Model</Text>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="ML model"
|
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={
|
after={
|
||||||
<SettingsSelect<DenoiseModelId>
|
<SettingsSelect<DenoiseModelId>
|
||||||
value={callDenoiseModel}
|
value={callDenoiseModel}
|
||||||
@@ -2064,9 +2064,9 @@ export function Calls() {
|
|||||||
<Box direction="Row" gap="400" wrap="Wrap">
|
<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: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
|
||||||
{ label: 'Transients', value: selectedDenoiseModel.transients },
|
{ label: 'Typing & clicks', value: selectedDenoiseModel.transients },
|
||||||
{ label: 'Download', value: selectedDenoiseModel.binarySize },
|
{ label: 'Download', value: selectedDenoiseModel.binarySize },
|
||||||
] as const
|
] as const
|
||||||
).map((stat) => (
|
).map((stat) => (
|
||||||
@@ -2113,7 +2113,7 @@ export function Calls() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
Transients
|
Typing & clicks
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -2137,7 +2137,7 @@ export function Calls() {
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<Text size="T200" priority="400">
|
<Text size="T200" priority="400">
|
||||||
Note: Applying changes requires rejoining the call.
|
Changes apply straight away, even during a call.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
|
|||||||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { isTauri } from '../../../hooks/useTauri';
|
||||||
|
import { useWebPushConfig, webPushSupported } from '../../../hooks/useWebPush';
|
||||||
|
|
||||||
function EmailNotification() {
|
function EmailNotification() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -197,6 +199,9 @@ function PauseNotifications() {
|
|||||||
export function SystemNotification() {
|
export function SystemNotification() {
|
||||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
|
const [backgroundPush, setBackgroundPush] = useSetting(settingsAtom, 'backgroundPush');
|
||||||
|
const webPushConfig = useWebPushConfig();
|
||||||
|
const showBackgroundPush = !isTauri() && webPushSupported() && webPushConfig !== undefined;
|
||||||
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
'isNotificationSounds',
|
'isNotificationSounds',
|
||||||
@@ -247,6 +252,19 @@ export function SystemNotification() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{showBackgroundPush && (
|
||||||
|
<SettingTile
|
||||||
|
title="Notify when Lotus is closed"
|
||||||
|
description="Keep getting notifications for new messages, mentions and calls when no Lotus tab is open, through your browser's push service. Encrypted messages are never shown in the notification. Pause and quiet hours still apply."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
disabled={notifPermission !== 'granted' || !showNotifications}
|
||||||
|
value={backgroundPush}
|
||||||
|
onChange={setBackgroundPush}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -42,6 +42,16 @@ export type ClientConfig = {
|
|||||||
*/
|
*/
|
||||||
statusPages?: Record<string, unknown>;
|
statusPages?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #202] Web Push gateway per homeserver:
|
||||||
|
* { "<server name>": { gatewayUrl, appId, vapidPublicKey } }. `gatewayUrl` is
|
||||||
|
* the push gateway's `/_matrix/push/v1/notify` URL, `appId` the Sygnal app
|
||||||
|
* id, `vapidPublicKey` that app's VAPID public key (base64url). Homeservers
|
||||||
|
* not listed never register a pusher. Web only (the desktop app notifies
|
||||||
|
* natively while running).
|
||||||
|
*/
|
||||||
|
webPush?: Record<string, unknown>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
|
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
|
||||||
* The desktop app sets it so it can hand calls it can't make to the browser.
|
* The desktop app sets it so it can hand calls it can't make to the browser.
|
||||||
|
|||||||
@@ -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;
|
if (stopped) return;
|
||||||
failures = 0;
|
failures = 0;
|
||||||
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
|
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups, page!.noticePrefix);
|
||||||
setStatus(last);
|
setStatus(last);
|
||||||
// While this client's connection is lost, keep asking every minute: that
|
// While this client's connection is lost, keep asking every minute: that
|
||||||
// is how it learns Kuma has checked the server since the drop.
|
// is how it learns Kuma has checked the server since the drop.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useClientConfig } from './useClientConfig';
|
||||||
|
import { getNotificationState, usePermissionState } from './usePermission';
|
||||||
|
import { isTauri } from './useTauri';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { notificationSnoozeUntilAtom } from '../state/notificationSnooze';
|
||||||
|
import {
|
||||||
|
base64UrlToBytes,
|
||||||
|
buildHttpPusher,
|
||||||
|
parseStoredPusher,
|
||||||
|
pusherDeviceName,
|
||||||
|
sameServerKey,
|
||||||
|
subscriptionKeys,
|
||||||
|
WEB_PUSH_STORAGE_KEY,
|
||||||
|
WebPushConfig,
|
||||||
|
webPushConfigFor,
|
||||||
|
} from '../utils/webPush';
|
||||||
|
|
||||||
|
/** Browser support for Web Push (iOS: only in the installed PWA). */
|
||||||
|
export const webPushSupported = (): boolean =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
'serviceWorker' in navigator &&
|
||||||
|
'PushManager' in window &&
|
||||||
|
'Notification' in window;
|
||||||
|
|
||||||
|
/** The gateway configured for the signed-in account's homeserver, if any. */
|
||||||
|
export function useWebPushConfig(): WebPushConfig | undefined {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { webPush } = useClientConfig();
|
||||||
|
return useMemo(() => webPushConfigFor(webPush, mx.getDomain()), [webPush, mx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readStored = () => {
|
||||||
|
try {
|
||||||
|
return parseStoredPusher(localStorage.getItem(WEB_PUSH_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePusher = (mx: MatrixClient, appId: string, pushkey: string): Promise<unknown> =>
|
||||||
|
mx
|
||||||
|
.setPusher({ kind: null, app_id: appId, pushkey } as unknown as IPusherRequest)
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
async function register(mx: MatrixClient, config: WebPushConfig): Promise<void> {
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (!userId) return;
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
let sub = await registration.pushManager.getSubscription();
|
||||||
|
if (sub && !sameServerKey(sub.options.applicationServerKey, config.vapidPublicKey)) {
|
||||||
|
await sub.unsubscribe();
|
||||||
|
sub = null;
|
||||||
|
}
|
||||||
|
if (!sub) {
|
||||||
|
sub = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: base64UrlToBytes(config.vapidPublicKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const keys = subscriptionKeys(sub.toJSON());
|
||||||
|
if (!keys) return;
|
||||||
|
|
||||||
|
// A previous registration of this browser for the same account with another
|
||||||
|
// key or app id would otherwise keep receiving (and failing) pushes.
|
||||||
|
const stored = readStored();
|
||||||
|
if (
|
||||||
|
stored &&
|
||||||
|
stored.userId === userId &&
|
||||||
|
(stored.pushkey !== keys.p256dh || stored.appId !== config.appId)
|
||||||
|
) {
|
||||||
|
await removePusher(mx, stored.appId, stored.pushkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = (navigator.language || 'en').split('-')[0] || 'en';
|
||||||
|
await mx.setPusher(
|
||||||
|
buildHttpPusher(
|
||||||
|
config,
|
||||||
|
keys,
|
||||||
|
userId,
|
||||||
|
pusherDeviceName(navigator.userAgent),
|
||||||
|
lang,
|
||||||
|
) as unknown as IPusherRequest,
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
WEB_PUSH_STORAGE_KEY,
|
||||||
|
JSON.stringify({ appId: config.appId, pushkey: keys.p256dh, userId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unregister(mx: MatrixClient): Promise<void> {
|
||||||
|
const stored = readStored();
|
||||||
|
if (!stored) return;
|
||||||
|
if (stored.userId === mx.getUserId()) await removePusher(mx, stored.appId, stored.pushkey);
|
||||||
|
localStorage.removeItem(WEB_PUSH_STORAGE_KEY);
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration();
|
||||||
|
const sub = await registration?.pushManager.getSubscription();
|
||||||
|
await sub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #202] Background notifications for the web app.
|
||||||
|
*
|
||||||
|
* - Mirrors the notification prefs (desktop notifications, background push,
|
||||||
|
* pause, quiet hours) into the service worker, which shows pushes while no
|
||||||
|
* Lotus tab is open.
|
||||||
|
* - While the homeserver has a gateway configured (config.json `webPush`), push
|
||||||
|
* is supported, permission is granted and both settings are on: subscribes
|
||||||
|
* this browser and registers it as an HTTP pusher. Otherwise removes
|
||||||
|
* whatever this browser registered before.
|
||||||
|
*
|
||||||
|
* Not used in the desktop app, which notifies natively while running.
|
||||||
|
*/
|
||||||
|
export function useWebPush(): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const config = useWebPushConfig();
|
||||||
|
const permission = usePermissionState('notifications', getNotificationState());
|
||||||
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
|
const [backgroundPush] = useSetting(settingsAtom, 'backgroundPush');
|
||||||
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
|
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||||
|
|
||||||
|
const active = !isTauri() && webPushSupported();
|
||||||
|
const enabled = showNotifications && backgroundPush;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((registration) =>
|
||||||
|
registration.active?.postMessage({
|
||||||
|
type: 'setPushPrefs',
|
||||||
|
prefs: { enabled, snoozeUntil, quietHoursEnabled, quietHoursStart, quietHoursEnd },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [active, enabled, snoozeUntil, quietHoursEnabled, quietHoursStart, quietHoursEnd]);
|
||||||
|
|
||||||
|
const wanted = active && !!config && permission === 'granted' && enabled;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const run = wanted && config ? register(mx, config) : unregister(mx);
|
||||||
|
run.catch((e) => {
|
||||||
|
// Push service unreachable, permission revoked meanwhile, etc. The app
|
||||||
|
// still notifies while open; nothing to surface beyond the console.
|
||||||
|
console.warn('[web push]', e);
|
||||||
|
});
|
||||||
|
}, [active, wanted, config, mx]);
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpd
|
|||||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||||
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
|
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
|
||||||
|
import { useWebPush } from '../../hooks/useWebPush';
|
||||||
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
|
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
|
||||||
import { ConsentRequiredPrompt } from '../../components/ConsentRequiredPrompt';
|
import { ConsentRequiredPrompt } from '../../components/ConsentRequiredPrompt';
|
||||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||||
@@ -614,17 +615,36 @@ function MessageNotifications() {
|
|||||||
if (!('serviceWorker' in navigator)) return undefined;
|
if (!('serviceWorker' in navigator)) return undefined;
|
||||||
const onMessage = (event: MessageEvent) => {
|
const onMessage = (event: MessageEvent) => {
|
||||||
const data = event.data ?? {};
|
const data = event.data ?? {};
|
||||||
if (data.type === 'notificationClick' && typeof data.path === 'string') {
|
if (data.type !== 'notificationClick') return;
|
||||||
// On desktop, raise the native window — a service-worker/WebView2
|
// [Gitea #202] Push notifications name the room, not a path: only the page
|
||||||
// `client.focus()` only focuses the document, not the OS window. No-op
|
// knows whether it's a DM.
|
||||||
// outside Tauri.
|
let path: string | undefined;
|
||||||
invokeTauri('focus_main_window');
|
if (typeof data.path === 'string') path = data.path;
|
||||||
navigate(data.path);
|
else if (typeof data.roomId === 'string') {
|
||||||
|
const eventId = typeof data.eventId === 'string' ? data.eventId : undefined;
|
||||||
|
path = mDirects.has(data.roomId)
|
||||||
|
? getDirectRoomPath(data.roomId, eventId)
|
||||||
|
: getHomeRoomPath(data.roomId, eventId);
|
||||||
}
|
}
|
||||||
|
if (!path) return;
|
||||||
|
// On desktop, raise the native window — a service-worker/WebView2
|
||||||
|
// `client.focus()` only focuses the document, not the OS window. No-op
|
||||||
|
// outside Tauri.
|
||||||
|
invokeTauri('focus_main_window');
|
||||||
|
navigate(path);
|
||||||
};
|
};
|
||||||
navigator.serviceWorker.addEventListener('message', onMessage);
|
navigator.serviceWorker.addEventListener('message', onMessage);
|
||||||
return () => navigator.serviceWorker.removeEventListener('message', onMessage);
|
return () => navigator.serviceWorker.removeEventListener('message', onMessage);
|
||||||
}, [navigate]);
|
}, [navigate, mDirects]);
|
||||||
|
|
||||||
|
// [Gitea #202] A notification clicked while no tab was open opened this one;
|
||||||
|
// ask the worker where it was meant to go (it holds the target for a minute).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((registration) => registration.active?.postMessage({ type: 'getPendingClick' }))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const playSound = useCallback(() => {
|
const playSound = useCallback(() => {
|
||||||
const audioElement = audioRef.current;
|
const audioElement = audioRef.current;
|
||||||
@@ -810,6 +830,12 @@ type ClientNonUIFeaturesProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Gitea #202] Web Push registration + prefs for the service worker (web only).
|
||||||
|
function WebPushFeature() {
|
||||||
|
useWebPush();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function DeepLinkNavigator() {
|
function DeepLinkNavigator() {
|
||||||
useDeepLinkNavigate();
|
useDeepLinkNavigate();
|
||||||
return null;
|
return null;
|
||||||
@@ -1165,6 +1191,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<StatusExpiryMonitor />
|
<StatusExpiryMonitor />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<WebPushFeature />
|
||||||
<ReminderMonitor />
|
<ReminderMonitor />
|
||||||
<RetentionSweeper />
|
<RetentionSweeper />
|
||||||
<TauriUpdateFeature />
|
<TauriUpdateFeature />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CallControlState } from './CallControlState';
|
|||||||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||||
import { canDelegateCapability } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
import { CallErrorState, parseCallError } from './callError';
|
import { CallErrorState, parseCallError } from './callError';
|
||||||
|
import type { LotusDenoisePayload } from '../../utils/callDenoise';
|
||||||
|
|
||||||
export enum CallControlEvent {
|
export enum CallControlEvent {
|
||||||
StateUpdate = 'state_update',
|
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).
|
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
|
||||||
private lastQuality: LotusQualityPayload | null = null;
|
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
|
// 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
|
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
|
||||||
// user-initiated unmute that auto-undeafens the user.
|
// user-initiated unmute that auto-undeafens the user.
|
||||||
@@ -191,6 +197,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.sendDeafenState();
|
this.sendDeafenState();
|
||||||
this.sendQuality();
|
this.sendQuality();
|
||||||
|
this.sendDenoise();
|
||||||
this.sendHotkeyCodes();
|
this.sendHotkeyCodes();
|
||||||
this.sendFrameScreenshareAllowed();
|
this.sendFrameScreenshareAllowed();
|
||||||
}
|
}
|
||||||
@@ -204,6 +211,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
public resendForkState(): void {
|
public resendForkState(): void {
|
||||||
this.sendDeafenState();
|
this.sendDeafenState();
|
||||||
this.sendQuality();
|
this.sendQuality();
|
||||||
|
this.sendDenoise();
|
||||||
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
|
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
|
||||||
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
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);
|
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() {
|
public dispose() {
|
||||||
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
||||||
this.clearFocusParticipant();
|
this.clearFocusParticipant();
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private themeKind: ElementCallThemeKind = 'dark';
|
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.
|
// Watchdog: detects an iframe that never reaches a usable state.
|
||||||
private loadWatchdog?: ReturnType<typeof setTimeout>;
|
private loadWatchdog?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
@@ -228,10 +237,14 @@ export class CallEmbed {
|
|||||||
denoiseMode === 'browser' ||
|
denoiseMode === 'browser' ||
|
||||||
(denoiseMode === 'ml' && denoiseNativeNS)
|
(denoiseMode === 'ml' && denoiseNativeNS)
|
||||||
).toString(),
|
).toString(),
|
||||||
// Turn the browser's auto gain control OFF for the ML tier only: its
|
// Keep the browser's auto gain control ON in every tier. It used to be
|
||||||
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
|
// turned off for the ML tier ("pumping"), but AGC is what brings a quiet
|
||||||
// tiers keep the browser's normal capture pipeline (AGC on).
|
// mic up to speaking level: without it ML-tier voices went out 10-20 dB
|
||||||
autoGainControl: (denoiseMode !== 'ml').toString(),
|
// 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(),
|
audio: initialAudio.toString(),
|
||||||
video: initialVideo.toString(),
|
video: initialVideo.toString(),
|
||||||
header: 'none',
|
header: 'none',
|
||||||
@@ -360,6 +373,7 @@ export class CallEmbed {
|
|||||||
this.iframe = iframe;
|
this.iframe = iframe;
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.themeKind = themeKind;
|
this.themeKind = themeKind;
|
||||||
|
this.loadedThemeKind = themeKind;
|
||||||
|
|
||||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||||
this.control = new CallControl(controlState, call, iframe);
|
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
|
// happy path (onCallJoined) clears it too; these cover earlier signals so a
|
||||||
// user sitting in the lobby/prescreen isn't flagged as an error.
|
// 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.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.onCapabilitiesNotified(() => this.settleLoad()));
|
||||||
this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));
|
this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));
|
||||||
|
|
||||||
@@ -399,12 +420,18 @@ export class CallEmbed {
|
|||||||
return this.room.roomId;
|
return this.room.roomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setTheme(theme: ElementCallThemeKind) {
|
public setTheme(theme: ElementCallThemeKind): Promise<unknown> {
|
||||||
this.themeKind = theme;
|
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
|
return this.call.transport
|
||||||
.send(WidgetApiToWidgetAction.ThemeChange, { name: theme })
|
.send(WidgetApiToWidgetAction.ThemeChange, { name: this.themeKind })
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Widget transport not ready yet; theme URL param is the fallback
|
// The frame went away mid-send; nothing to do.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ export interface Settings {
|
|||||||
legacyUsernameColor: boolean;
|
legacyUsernameColor: boolean;
|
||||||
|
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
|
// [Gitea #202] Web only: notifications while no Lotus tab is open, via Web
|
||||||
|
// Push and the homeserver's push gateway. Device-local (per browser).
|
||||||
|
backgroundPush: boolean;
|
||||||
isNotificationSounds: boolean;
|
isNotificationSounds: boolean;
|
||||||
messageSoundId: 'notification' | 'invite' | 'call' | 'none';
|
messageSoundId: 'notification' | 'invite' | 'call' | 'none';
|
||||||
inviteSoundId: 'notification' | 'invite' | 'call' | 'none';
|
inviteSoundId: 'notification' | 'invite' | 'call' | 'none';
|
||||||
@@ -356,6 +359,7 @@ const defaultSettings: Settings = {
|
|||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
|
backgroundPush: true,
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
messageSoundId: 'notification',
|
messageSoundId: 'notification',
|
||||||
inviteSoundId: 'invite',
|
inviteSoundId: 'invite',
|
||||||
@@ -377,9 +381,11 @@ const defaultSettings: Settings = {
|
|||||||
|
|
||||||
cameraOnJoin: false,
|
cameraOnJoin: false,
|
||||||
// Tier default stays browser-native (known-good; best-perceived in testing so
|
// 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',
|
callNoiseSuppression: 'browser',
|
||||||
callDenoiseModel: 'deepfilternet',
|
callDenoiseModel: 'dtln',
|
||||||
// "Series suppression" (stack the browser's native NS before the ML model) is
|
// "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.
|
// off by default — best practice is a single NS stage; it's an opt-in test aid.
|
||||||
callDenoiseNativeNS: false,
|
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);
|
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 = {
|
export type DenoiseNode = {
|
||||||
node: AudioWorkletNode;
|
node: AudioWorkletNode;
|
||||||
dispose: () => void;
|
dispose: () => void;
|
||||||
@@ -85,7 +99,10 @@ export async function buildModelNode(
|
|||||||
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
|
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
|
||||||
const core = new mod.DeepFilterNet3Core({
|
const core = new mod.DeepFilterNet3Core({
|
||||||
sampleRate: sampleRateFor(model),
|
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 },
|
assetConfig: { cdnUrl: dfnBase },
|
||||||
});
|
});
|
||||||
await core.initialize();
|
await core.initialize();
|
||||||
@@ -105,6 +122,8 @@ export async function buildModelNode(
|
|||||||
const [, wasmBinary] = await Promise.all([addModuleOnce(ctx, cfg.script), fetchWasm(cfg.wasm)]);
|
const [, wasmBinary] = await Promise.all([addModuleOnce(ctx, cfg.script), fetchWasm(cfg.wasm)]);
|
||||||
const node = new AudioWorkletNode(ctx, cfg.proc, {
|
const node = new AudioWorkletNode(ctx, cfg.proc, {
|
||||||
channelCount: 1,
|
channelCount: 1,
|
||||||
|
channelCountMode: 'explicit',
|
||||||
|
channelInterpretation: 'speakers',
|
||||||
numberOfInputs: 1,
|
numberOfInputs: 1,
|
||||||
numberOfOutputs: 1,
|
numberOfOutputs: 1,
|
||||||
processorOptions: { maxChannels: 1, wasmBinary },
|
processorOptions: { maxChannels: 1, wasmBinary },
|
||||||
@@ -127,6 +146,9 @@ export async function buildGateNode(
|
|||||||
): Promise<AudioWorkletNode> {
|
): Promise<AudioWorkletNode> {
|
||||||
await addModuleOnce(ctx, 'noiseGateWorklet.js');
|
await addModuleOnce(ctx, 'noiseGateWorklet.js');
|
||||||
return new AudioWorkletNode(ctx, '@sapphi-red/web-noise-suppressor/noise-gate', {
|
return new AudioWorkletNode(ctx, '@sapphi-red/web-noise-suppressor/noise-gate', {
|
||||||
|
channelCount: 1,
|
||||||
|
channelCountMode: 'explicit',
|
||||||
|
channelInterpretation: 'speakers',
|
||||||
processorOptions: {
|
processorOptions: {
|
||||||
openThreshold: thresholdDb,
|
openThreshold: thresholdDb,
|
||||||
closeThreshold: thresholdDb - 5,
|
closeThreshold: thresholdDb - 5,
|
||||||
|
|||||||
@@ -285,3 +285,75 @@ test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)',
|
|||||||
'Read the guide docs',
|
'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" } } }
|
* "login": "Login" } } }
|
||||||
* Users of any other homeserver never contact Kuma.
|
* 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:
|
* Kuma 2.x public JSON:
|
||||||
* - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}],
|
* - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}],
|
||||||
* maintenanceList (only windows UNDER maintenance right now), incidents
|
* maintenanceList (only windows UNDER maintenance right now), incidents
|
||||||
@@ -19,7 +24,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type StatusGroups = { homeserver?: string; calls?: string; login?: string };
|
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 ServiceState = 'up' | 'down' | 'unknown';
|
||||||
export type KumaMaintenance = { id: string; title: string; description: string; end?: number };
|
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';
|
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
||||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
|
||||||
const g = obj(entry.groups) ?? {};
|
const g = obj(entry.groups) ?? {};
|
||||||
|
const noticePrefix = str(entry.noticePrefix).trim();
|
||||||
return {
|
return {
|
||||||
url: url.origin,
|
url: url.origin,
|
||||||
slug,
|
slug,
|
||||||
|
...(noticePrefix ? { noticePrefix } : {}),
|
||||||
groups: {
|
groups: {
|
||||||
homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver,
|
homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver,
|
||||||
calls: str(g.calls) || DEFAULT_GROUPS.calls,
|
calls: str(g.calls) || DEFAULT_GROUPS.calls,
|
||||||
@@ -133,10 +145,18 @@ const groupState = (
|
|||||||
return known > 0 ? 'up' : 'unknown';
|
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 = (
|
export const parseKumaStatus = (
|
||||||
pageJson: unknown,
|
pageJson: unknown,
|
||||||
heartbeatJson: unknown,
|
heartbeatJson: unknown,
|
||||||
groupNames: StatusGroups,
|
groupNames: StatusGroups,
|
||||||
|
noticePrefix?: string,
|
||||||
): ServerStatus => {
|
): ServerStatus => {
|
||||||
const page = obj(pageJson) ?? {};
|
const page = obj(pageJson) ?? {};
|
||||||
const groups = new Map<string, string[]>();
|
const groups = new Map<string, string[]>();
|
||||||
@@ -155,14 +175,14 @@ export const parseKumaStatus = (
|
|||||||
Array.isArray(page.maintenanceList) ? page.maintenanceList : []
|
Array.isArray(page.maintenanceList) ? page.maintenanceList : []
|
||||||
)
|
)
|
||||||
.map((m) => obj(m))
|
.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')
|
.filter((m) => !m.status || m.status === 'under-maintenance')
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]);
|
const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]);
|
||||||
const end = Date.parse(str(slot?.endDate));
|
const end = Date.parse(str(slot?.endDate));
|
||||||
return {
|
return {
|
||||||
id: String(m.id ?? str(m.title)),
|
id: String(m.id ?? str(m.title)),
|
||||||
title: str(m.title),
|
title: noticeTitle(str(m.title), noticePrefix) ?? '',
|
||||||
description: str(m.description),
|
description: str(m.description),
|
||||||
...(Number.isFinite(end) ? { end } : {}),
|
...(Number.isFinite(end) ? { end } : {}),
|
||||||
};
|
};
|
||||||
@@ -170,10 +190,13 @@ export const parseKumaStatus = (
|
|||||||
|
|
||||||
const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : [])
|
const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : [])
|
||||||
.map((i) => obj(i))
|
.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) => ({
|
.map((i) => ({
|
||||||
id: String(i.id ?? str(i.title)),
|
id: String(i.id ?? str(i.title)),
|
||||||
title: str(i.title),
|
title: noticeTitle(str(i.title), noticePrefix) ?? '',
|
||||||
content: str(i.content),
|
content: str(i.content),
|
||||||
style: str(i.style) || 'info',
|
style: str(i.style) || 'info',
|
||||||
updated: str(i.lastUpdatedDate) || str(i.createdDate),
|
updated: str(i.lastUpdatedDate) || str(i.createdDate),
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { DENOISE_MODELS, ML_DENOISE_REQUIREMENTS, isMLDenoiseSupported } from '.
|
|||||||
|
|
||||||
// ── Model catalog (data integrity) ──────────────────────────────────────────
|
// ── 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(
|
assert.deepEqual(
|
||||||
DENOISE_MODELS.map((m) => m.id),
|
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
|
* Detection utilities + model catalog for Lotus ML noise suppression
|
||||||
* (DeepFilterNet 3 / DTLN / RNNoise / Speex). The catalog is ordered by
|
* (DTLN / DeepFilterNet 3 / RNNoise / Speex). The catalog is ordered by
|
||||||
* quality (and, correspondingly, CPU cost) — highest first — and drives the
|
* recommendation — most recommended first — and drives the order of the model
|
||||||
* order of the model dropdown in settings.
|
* dropdown in settings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DenoiseModelId } from '../state/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 = {
|
export type DenoiseModel = {
|
||||||
id: DenoiseModelId;
|
id: DenoiseModelId;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,25 +23,25 @@ export type DenoiseModel = {
|
|||||||
voiceQuality: 'Moderate' | 'High' | 'Very High';
|
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[] = [
|
export const DENOISE_MODELS: DenoiseModel[] = [
|
||||||
{
|
{
|
||||||
id: 'deepfilternet',
|
id: 'dtln',
|
||||||
name: 'DeepFilterNet 3 (beta)',
|
name: 'DTLN (recommended)',
|
||||||
description:
|
description:
|
||||||
'Studio-grade deep-learning model (48 kHz fullband, ONNX). Best quality; highest CPU and a larger one-time download.',
|
'Keeps voices the most natural of the four. Removes fans and typing well, but not traffic or people talking in the background.',
|
||||||
cpuUsage: '25-50%',
|
cpuUsage: '~9%',
|
||||||
binarySize: '~18 MB',
|
binarySize: '~20 MB',
|
||||||
transients: 'Excellent',
|
transients: 'Excellent',
|
||||||
voiceQuality: 'Very High',
|
voiceQuality: 'Very High',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dtln',
|
id: 'deepfilternet',
|
||||||
name: 'DTLN (beta)',
|
name: 'DeepFilterNet 3 (strongest)',
|
||||||
description:
|
description:
|
||||||
'Dual-signal deep-learning model (16 kHz). Strong on transient noise; moderate CPU.',
|
'Removes the most noise, including loud typing and traffic, but voices can sound slightly muffled. Uses about three times the CPU of DTLN.',
|
||||||
cpuUsage: '10-20%',
|
cpuUsage: '~30%',
|
||||||
binarySize: '~4 MB',
|
binarySize: '~18 MB',
|
||||||
transients: 'Excellent',
|
transients: 'Excellent',
|
||||||
voiceQuality: 'High',
|
voiceQuality: 'High',
|
||||||
},
|
},
|
||||||
@@ -43,18 +49,18 @@ export const DENOISE_MODELS: DenoiseModel[] = [
|
|||||||
id: 'rnnoise',
|
id: 'rnnoise',
|
||||||
name: 'RNNoise',
|
name: 'RNNoise',
|
||||||
description:
|
description:
|
||||||
'Lightweight hybrid model (48 kHz). Very low CPU; good for steady noise like fans, but can sound processed at full strength.',
|
'Very light. Handles steady noise like fans and traffic; does little for typing and can sound slightly processed.',
|
||||||
cpuUsage: '< 5%',
|
cpuUsage: '~3%',
|
||||||
binarySize: '< 1 MB',
|
binarySize: '< 1 MB',
|
||||||
transients: 'Good',
|
transients: 'Poor',
|
||||||
voiceQuality: 'Moderate',
|
voiceQuality: 'Moderate',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'speex',
|
id: 'speex',
|
||||||
name: 'Speex (Legacy)',
|
name: 'Speex (legacy)',
|
||||||
description:
|
description:
|
||||||
'Classic DSP noise suppressor. Minimal CPU, gentlest on voice; weakest suppression.',
|
'Classic suppressor kept for very slow computers. Barely reduces noise in our tests; prefer any other option.',
|
||||||
cpuUsage: '< 2%',
|
cpuUsage: '~2%',
|
||||||
binarySize: '< 1 MB',
|
binarySize: '< 1 MB',
|
||||||
transients: 'Poor',
|
transients: 'Poor',
|
||||||
voiceQuality: 'Moderate',
|
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(new Error('Boom')), 'Boom');
|
||||||
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
|
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';
|
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.';
|
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).
|
* Schedule a message via MSC4140 (delayed messages).
|
||||||
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
* 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 txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
||||||
// Use standard v3 prefix with the MSC4140 delay query param.
|
// Use standard v3 prefix with the MSC4140 delay query param.
|
||||||
const res = (await mx.http.authedRequest(
|
const res = (await withRateLimitRetry(() =>
|
||||||
Method.Put,
|
mx.http.authedRequest(
|
||||||
path,
|
Method.Put,
|
||||||
{
|
path,
|
||||||
'org.matrix.msc4140.delay': delayMs,
|
{
|
||||||
},
|
'org.matrix.msc4140.delay': delayMs,
|
||||||
content,
|
},
|
||||||
|
content,
|
||||||
|
),
|
||||||
)) as { delay_id: string };
|
)) as { delay_id: string };
|
||||||
return res.delay_id;
|
return res.delay_id;
|
||||||
}
|
}
|
||||||
@@ -58,23 +94,27 @@ export async function scheduleMessage(
|
|||||||
*/
|
*/
|
||||||
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
||||||
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
||||||
await mx.http.authedRequest(
|
await withRateLimitRetry(() =>
|
||||||
Method.Post,
|
mx.http.authedRequest(
|
||||||
path,
|
Method.Post,
|
||||||
undefined,
|
path,
|
||||||
{ action: 'cancel' },
|
undefined,
|
||||||
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
{ action: 'cancel' },
|
||||||
|
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
||||||
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
||||||
await mx.http.authedRequest(
|
await withRateLimitRetry(() =>
|
||||||
Method.Post,
|
mx.http.authedRequest(
|
||||||
path,
|
Method.Post,
|
||||||
undefined,
|
path,
|
||||||
{ action: 'restart' },
|
undefined,
|
||||||
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
{ 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> {
|
export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> {
|
||||||
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
||||||
await mx.http.authedRequest(
|
await withRateLimitRetry(() =>
|
||||||
Method.Post,
|
mx.http.authedRequest(
|
||||||
path,
|
Method.Post,
|
||||||
undefined,
|
path,
|
||||||
{ action: 'send' },
|
undefined,
|
||||||
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
{ 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. */
|
/** The user's pending scheduled messages, as the server has them. */
|
||||||
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
|
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
|
||||||
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
|
const res = await withRateLimitRetry(() =>
|
||||||
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
|
mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
|
||||||
});
|
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
|
||||||
|
}),
|
||||||
|
);
|
||||||
return parseDelayedEvents(res);
|
return parseDelayedEvents(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type SyncedSettingsContent = {
|
|||||||
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
||||||
'settingsSync',
|
'settingsSync',
|
||||||
'globalCallHotkeys',
|
'globalCallHotkeys',
|
||||||
|
'backgroundPush',
|
||||||
'pageZoom',
|
'pageZoom',
|
||||||
'mediaAutoLoad',
|
'mediaAutoLoad',
|
||||||
'pauseAnimations',
|
'pauseAnimations',
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
base64UrlToBytes,
|
||||||
|
buildHttpPusher,
|
||||||
|
bytesToBase64Url,
|
||||||
|
parseStoredPusher,
|
||||||
|
pusherDeviceName,
|
||||||
|
sameServerKey,
|
||||||
|
subscriptionKeys,
|
||||||
|
webPushConfigFor,
|
||||||
|
} from './webPush';
|
||||||
|
|
||||||
|
// A real-shaped VAPID public key (65-byte uncompressed P-256 point, base64url).
|
||||||
|
const VAPID =
|
||||||
|
'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';
|
||||||
|
const CONFIG = {
|
||||||
|
'matrix.example.org': {
|
||||||
|
gatewayUrl: 'https://push.example.org/_matrix/push/v1/notify',
|
||||||
|
appId: 'org.example.chat.web',
|
||||||
|
vapidPublicKey: VAPID,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('config is per homeserver and only used when well-formed', () => {
|
||||||
|
assert.deepEqual(webPushConfigFor(CONFIG, 'matrix.example.org'), CONFIG['matrix.example.org']);
|
||||||
|
assert.equal(webPushConfigFor(CONFIG, 'matrix.org'), undefined);
|
||||||
|
assert.equal(webPushConfigFor(undefined, 'matrix.example.org'), undefined);
|
||||||
|
assert.equal(webPushConfigFor(CONFIG, null), undefined);
|
||||||
|
const bad = (over: Record<string, unknown>) =>
|
||||||
|
webPushConfigFor({ s: { ...CONFIG['matrix.example.org'], ...over } }, 's');
|
||||||
|
assert.equal(bad({ gatewayUrl: 'http://push.example.org/notify' }), undefined);
|
||||||
|
assert.equal(bad({ gatewayUrl: 'not a url' }), undefined);
|
||||||
|
assert.equal(bad({ appId: '' }), undefined);
|
||||||
|
assert.equal(bad({ vapidPublicKey: 'short' }), undefined);
|
||||||
|
assert.equal(
|
||||||
|
bad({ vapidPublicKey: 'has spaces and +/= that are not base64url!!!!!!!!!!!!' }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('base64url round-trips and decodes the VAPID key to 65 bytes', () => {
|
||||||
|
const bytes = base64UrlToBytes(VAPID);
|
||||||
|
assert.equal(bytes.length, 65);
|
||||||
|
assert.equal(bytes[0], 0x04);
|
||||||
|
assert.equal(bytesToBase64Url(bytes), VAPID);
|
||||||
|
assert.equal(bytesToBase64Url(bytes.buffer), VAPID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an existing subscription is reused only with the same server key', () => {
|
||||||
|
const key = base64UrlToBytes(VAPID).buffer;
|
||||||
|
assert.equal(sameServerKey(key, VAPID), true);
|
||||||
|
assert.equal(sameServerKey(key, `${VAPID}=`), true);
|
||||||
|
assert.equal(sameServerKey(new Uint8Array([4, 1, 2]).buffer, VAPID), false);
|
||||||
|
assert.equal(sameServerKey(null, VAPID), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription keys are taken from PushSubscription.toJSON()', () => {
|
||||||
|
const json = {
|
||||||
|
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||||
|
keys: { p256dh: 'P256', auth: 'AUTH' },
|
||||||
|
};
|
||||||
|
assert.deepEqual(subscriptionKeys(json), {
|
||||||
|
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
|
||||||
|
p256dh: 'P256',
|
||||||
|
auth: 'AUTH',
|
||||||
|
});
|
||||||
|
assert.equal(subscriptionKeys({ ...json, endpoint: 'http://insecure' }), undefined);
|
||||||
|
assert.equal(subscriptionKeys({ endpoint: json.endpoint, keys: { p256dh: 'P' } }), undefined);
|
||||||
|
assert.equal(subscriptionKeys(null), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the pusher is a Sygnal webpush pusher keyed by p256dh', () => {
|
||||||
|
const pusher = buildHttpPusher(
|
||||||
|
CONFIG['matrix.example.org'],
|
||||||
|
{ endpoint: 'https://push.service/abc', p256dh: 'P256', auth: 'AUTH' },
|
||||||
|
'@me:example.org',
|
||||||
|
'Lotus Chat (Firefox on Linux)',
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
assert.deepEqual(pusher, {
|
||||||
|
kind: 'http',
|
||||||
|
app_id: 'org.example.chat.web',
|
||||||
|
pushkey: 'P256',
|
||||||
|
app_display_name: 'Lotus Chat',
|
||||||
|
device_display_name: 'Lotus Chat (Firefox on Linux)',
|
||||||
|
lang: 'en',
|
||||||
|
data: {
|
||||||
|
url: 'https://push.example.org/_matrix/push/v1/notify',
|
||||||
|
endpoint: 'https://push.service/abc',
|
||||||
|
auth: 'AUTH',
|
||||||
|
events_only: true,
|
||||||
|
default_payload: { user_id: '@me:example.org' },
|
||||||
|
},
|
||||||
|
append: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device names', () => {
|
||||||
|
assert.equal(
|
||||||
|
pusherDeviceName('Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0'),
|
||||||
|
'Lotus Chat (Firefox on Linux)',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
pusherDeviceName(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36 Edg/140.0',
|
||||||
|
),
|
||||||
|
'Lotus Chat (Edge on Windows)',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
pusherDeviceName(
|
||||||
|
'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Mobile Safari/537.36',
|
||||||
|
),
|
||||||
|
'Lotus Chat (Chrome on Android)',
|
||||||
|
);
|
||||||
|
assert.equal(pusherDeviceName(''), 'Lotus Chat (Browser)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stored pusher parsing', () => {
|
||||||
|
assert.deepEqual(parseStoredPusher('{"appId":"a","pushkey":"k","userId":"@u:x"}'), {
|
||||||
|
appId: 'a',
|
||||||
|
pushkey: 'k',
|
||||||
|
userId: '@u:x',
|
||||||
|
});
|
||||||
|
assert.equal(parseStoredPusher(null), undefined);
|
||||||
|
assert.equal(parseStoredPusher('nope'), undefined);
|
||||||
|
assert.equal(parseStoredPusher('{"appId":"a"}'), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #202] Web Push: pure helpers for registering this browser with the
|
||||||
|
* homeserver as an HTTP pusher that points at our push gateway (Sygnal,
|
||||||
|
* webpush kind). `useWebPush` drives them; the service worker's `push`
|
||||||
|
* handler (src/swPush.ts) shows what arrives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type WebPushConfig = {
|
||||||
|
/** The gateway's notify URL, e.g. https://push.example.org/_matrix/push/v1/notify */
|
||||||
|
gatewayUrl: string;
|
||||||
|
/** Sygnal app id for the web client. */
|
||||||
|
appId: string;
|
||||||
|
/** That app's VAPID public key, base64url (uncompressed P-256 point). */
|
||||||
|
vapidPublicKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHttpsUrl = (v: unknown): v is string => {
|
||||||
|
if (typeof v !== 'string') return false;
|
||||||
|
try {
|
||||||
|
return new URL(v).protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gateway configured for this homeserver, or `undefined` (then nothing is
|
||||||
|
* registered). Malformed entries are ignored rather than half-used.
|
||||||
|
*/
|
||||||
|
export function webPushConfigFor(
|
||||||
|
webPush: Record<string, unknown> | undefined,
|
||||||
|
serverName: string | null | undefined,
|
||||||
|
): WebPushConfig | undefined {
|
||||||
|
if (!webPush || !serverName) return undefined;
|
||||||
|
const raw = webPush[serverName];
|
||||||
|
if (!raw || typeof raw !== 'object') return undefined;
|
||||||
|
const c = raw as Record<string, unknown>;
|
||||||
|
if (!isHttpsUrl(c.gatewayUrl)) return undefined;
|
||||||
|
if (typeof c.appId !== 'string' || c.appId === '') return undefined;
|
||||||
|
if (typeof c.vapidPublicKey !== 'string' || !/^[A-Za-z0-9_-]{40,}$/.test(c.vapidPublicKey)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { gatewayUrl: c.gatewayUrl, appId: c.appId, vapidPublicKey: c.vapidPublicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** base64url (no padding) → bytes, for `applicationServerKey`. */
|
||||||
|
export function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
||||||
|
const bin = atob(padded);
|
||||||
|
const out = new Uint8Array(new ArrayBuffer(bin.length));
|
||||||
|
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bytes → base64url (no padding). */
|
||||||
|
export function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string {
|
||||||
|
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||||
|
let bin = '';
|
||||||
|
arr.forEach((b) => {
|
||||||
|
bin += String.fromCharCode(b);
|
||||||
|
});
|
||||||
|
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an existing subscription was made with this VAPID key. */
|
||||||
|
export function sameServerKey(
|
||||||
|
subscriptionKey: ArrayBuffer | null | undefined,
|
||||||
|
vapidPublicKey: string,
|
||||||
|
): boolean {
|
||||||
|
if (!subscriptionKey) return false;
|
||||||
|
return bytesToBase64Url(subscriptionKey) === vapidPublicKey.replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionKeys = { endpoint: string; p256dh: string; auth: string };
|
||||||
|
|
||||||
|
/** The parts of a `PushSubscription.toJSON()` a pusher needs, or `undefined`. */
|
||||||
|
export function subscriptionKeys(json: unknown): SubscriptionKeys | undefined {
|
||||||
|
if (!json || typeof json !== 'object') return undefined;
|
||||||
|
const j = json as { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown } };
|
||||||
|
if (!isHttpsUrl(j.endpoint)) return undefined;
|
||||||
|
const { p256dh, auth } = j.keys ?? {};
|
||||||
|
if (typeof p256dh !== 'string' || typeof auth !== 'string' || !p256dh || !auth) return undefined;
|
||||||
|
return { endpoint: j.endpoint, p256dh, auth };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpPusherRequest = {
|
||||||
|
kind: 'http';
|
||||||
|
app_id: string;
|
||||||
|
pushkey: string;
|
||||||
|
app_display_name: string;
|
||||||
|
device_display_name: string;
|
||||||
|
lang: string;
|
||||||
|
data: {
|
||||||
|
url: string;
|
||||||
|
endpoint: string;
|
||||||
|
auth: string;
|
||||||
|
/** Sygnal: skip count-only pushes (browsers want a notification per push). */
|
||||||
|
events_only: boolean;
|
||||||
|
/** Merged into every push payload: which account it's for. */
|
||||||
|
default_payload: { user_id: string };
|
||||||
|
};
|
||||||
|
append: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `/pushers/set` body for a Sygnal webpush pusher: the pushkey is the
|
||||||
|
* subscription's p256dh key, the endpoint and auth secret go in `data`.
|
||||||
|
* `append` keeps the same browser registered for another account too.
|
||||||
|
*/
|
||||||
|
export function buildHttpPusher(
|
||||||
|
config: WebPushConfig,
|
||||||
|
keys: SubscriptionKeys,
|
||||||
|
userId: string,
|
||||||
|
deviceName: string,
|
||||||
|
lang: string,
|
||||||
|
): HttpPusherRequest {
|
||||||
|
return {
|
||||||
|
kind: 'http',
|
||||||
|
app_id: config.appId,
|
||||||
|
pushkey: keys.p256dh,
|
||||||
|
app_display_name: 'Lotus Chat',
|
||||||
|
device_display_name: deviceName,
|
||||||
|
lang,
|
||||||
|
data: {
|
||||||
|
url: config.gatewayUrl,
|
||||||
|
endpoint: keys.endpoint,
|
||||||
|
auth: keys.auth,
|
||||||
|
events_only: true,
|
||||||
|
default_payload: { user_id: userId },
|
||||||
|
},
|
||||||
|
append: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short "Lotus Chat (Firefox on Linux)" style name for the pusher list. */
|
||||||
|
export function pusherDeviceName(userAgent: string): string {
|
||||||
|
const browser =
|
||||||
|
(/Edg\//.test(userAgent) && 'Edge') ||
|
||||||
|
(/Firefox\//.test(userAgent) && 'Firefox') ||
|
||||||
|
(/Chrome\//.test(userAgent) && 'Chrome') ||
|
||||||
|
(/Safari\//.test(userAgent) && 'Safari') ||
|
||||||
|
'Browser';
|
||||||
|
const os =
|
||||||
|
(/Android/.test(userAgent) && 'Android') ||
|
||||||
|
(/iPhone|iPad|iPod/.test(userAgent) && 'iOS') ||
|
||||||
|
(/Windows/.test(userAgent) && 'Windows') ||
|
||||||
|
(/Mac OS X/.test(userAgent) && 'macOS') ||
|
||||||
|
(/Linux/.test(userAgent) && 'Linux') ||
|
||||||
|
'';
|
||||||
|
return os ? `Lotus Chat (${browser} on ${os})` : `Lotus Chat (${browser})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What this browser registered, so it can be removed again. */
|
||||||
|
export type StoredPusher = { appId: string; pushkey: string; userId: string };
|
||||||
|
|
||||||
|
export const WEB_PUSH_STORAGE_KEY = 'lotus_web_push_pusher_v1';
|
||||||
|
|
||||||
|
export function parseStoredPusher(raw: string | null): StoredPusher | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as Partial<StoredPusher>;
|
||||||
|
if (
|
||||||
|
typeof v.appId === 'string' &&
|
||||||
|
typeof v.pushkey === 'string' &&
|
||||||
|
typeof v.userId === 'string'
|
||||||
|
) {
|
||||||
|
return { appId: v.appId, pushkey: v.pushkey, userId: v.userId };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -3,6 +3,16 @@
|
|||||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||||
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||||
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
||||||
|
import {
|
||||||
|
buildPushNotification,
|
||||||
|
isPushQuiet,
|
||||||
|
parsePushPrefs,
|
||||||
|
PENDING_CLICK_MAX_AGE_MS,
|
||||||
|
PUSH_PREFS_KEY,
|
||||||
|
PushPayload,
|
||||||
|
PushPrefs,
|
||||||
|
SW_STATE_CACHE,
|
||||||
|
} from './swPush';
|
||||||
|
|
||||||
export type {};
|
export type {};
|
||||||
declare const self: ServiceWorkerGlobalScope & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
@@ -142,6 +152,99 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|||||||
setSession(client.id, accessToken, baseUrl, userId);
|
setSession(client.id, accessToken, baseUrl, userId);
|
||||||
cleanupDeadClients();
|
cleanupDeadClients();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #202] The page mirrors its notification prefs here so pushes that
|
||||||
|
// arrive with no tab open still honour pause / quiet hours.
|
||||||
|
if (type === 'setPushPrefs') {
|
||||||
|
event.waitUntil(savePushPrefs(parsePushPrefs(event.data.prefs)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Gitea #202] A window opened by a notification click asks where to go once
|
||||||
|
// its router is listening.
|
||||||
|
if (type === 'getPendingClick') {
|
||||||
|
const pending = takePendingClick();
|
||||||
|
if (pending) client.postMessage({ type: 'notificationClick', ...pending });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// [Gitea #202] Web Push
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function savePushPrefs(prefs: PushPrefs): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(SW_STATE_CACHE);
|
||||||
|
await cache.put(
|
||||||
|
PUSH_PREFS_KEY,
|
||||||
|
new Response(JSON.stringify(prefs), { headers: { 'Content-Type': 'application/json' } }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable: pushes fall back to the defaults.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPushPrefs(): Promise<PushPrefs> {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(SW_STATE_CACHE);
|
||||||
|
const res = await cache.match(PUSH_PREFS_KEY);
|
||||||
|
return parsePushPrefs(res ? await res.json() : undefined);
|
||||||
|
} catch {
|
||||||
|
return parsePushPrefs(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClickTarget = { path?: string; roomId?: string; eventId?: string };
|
||||||
|
let pendingClick: (ClickTarget & { at: number }) | undefined;
|
||||||
|
|
||||||
|
function takePendingClick(): ClickTarget | undefined {
|
||||||
|
const pending = pendingClick;
|
||||||
|
pendingClick = undefined;
|
||||||
|
if (!pending || Date.now() - pending.at > PENDING_CLICK_MAX_AGE_MS) return undefined;
|
||||||
|
const { path, roomId, eventId } = pending;
|
||||||
|
return { path, roomId, eventId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAppBadge(count: number | undefined): Promise<void> {
|
||||||
|
const nav = self.navigator as WorkerNavigator & {
|
||||||
|
setAppBadge?: (n?: number) => Promise<void>;
|
||||||
|
clearAppBadge?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
if (typeof count !== 'number') return;
|
||||||
|
try {
|
||||||
|
if (count > 0) await nav.setAppBadge?.(count);
|
||||||
|
else await nav.clearAppBadge?.();
|
||||||
|
} catch {
|
||||||
|
// Badging unsupported or not allowed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('push', (event: PushEvent) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
let payload: PushPayload = {};
|
||||||
|
try {
|
||||||
|
payload = (event.data?.json() ?? {}) as PushPayload;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setAppBadge(payload.unread);
|
||||||
|
|
||||||
|
const built = buildPushNotification(payload, self.registration.scope);
|
||||||
|
if (!built) return;
|
||||||
|
|
||||||
|
// A focused Lotus tab already shows this as an in-app toast.
|
||||||
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
if (windows.some((c) => (c as WindowClient).focused)) return;
|
||||||
|
|
||||||
|
const prefs = await loadPushPrefs();
|
||||||
|
if (!prefs.enabled) return;
|
||||||
|
const quiet = isPushQuiet(prefs, new Date());
|
||||||
|
await self.registration.showNotification(built.title, {
|
||||||
|
...built.options,
|
||||||
|
silent: quiet,
|
||||||
|
} as NotificationOptions);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,6 +257,10 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
|||||||
event.notification.close();
|
event.notification.close();
|
||||||
const data = event.notification.data ?? {};
|
const data = event.notification.data ?? {};
|
||||||
const path = typeof data.path === 'string' ? data.path : undefined;
|
const path = typeof data.path === 'string' ? data.path : undefined;
|
||||||
|
// [Gitea #202] Push notifications carry the room instead of a path: only the
|
||||||
|
// page knows whether it's a DM.
|
||||||
|
const roomId = typeof data.roomId === 'string' ? data.roomId : undefined;
|
||||||
|
const eventId = typeof data.eventId === 'string' ? data.eventId : undefined;
|
||||||
|
|
||||||
// [Gitea #203] Reply action: send it and stop — no need to raise the app.
|
// [Gitea #203] Reply action: send it and stop — no need to raise the app.
|
||||||
if (event.action === 'reply' && typeof data.roomId === 'string') {
|
if (event.action === 'reply' && typeof data.roomId === 'string') {
|
||||||
@@ -202,12 +309,15 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
|||||||
windowClients[0];
|
windowClients[0];
|
||||||
if (client) {
|
if (client) {
|
||||||
await client.focus();
|
await client.focus();
|
||||||
if (path) client.postMessage({ type: 'notificationClick', path });
|
if (path || roomId)
|
||||||
|
client.postMessage({ type: 'notificationClick', path, roomId, eventId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No app window open — open one at the app root (the client routes from
|
// No app window open — open one at the app root (the client routes from
|
||||||
// there). Router config (hash vs browser) is page-owned, so we don't try
|
// there). Router config (hash vs browser) is page-owned, so we don't try
|
||||||
// to deep-link the URL here.
|
// to deep-link the URL here; the new window asks for the target
|
||||||
|
// (`getPendingClick`) once it can route.
|
||||||
|
if (path || roomId) pendingClick = { path, roomId, eventId, at: Date.now() };
|
||||||
if (self.clients.openWindow) {
|
if (self.clients.openWindow) {
|
||||||
await self.clients.openWindow(self.registration.scope);
|
await self.clients.openWindow(self.registration.scope);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
buildPushNotification,
|
||||||
|
DEFAULT_PUSH_PREFS,
|
||||||
|
isPushQuiet,
|
||||||
|
parsePushPrefs,
|
||||||
|
PushPayload,
|
||||||
|
} from './swPush';
|
||||||
|
|
||||||
|
const SCOPE = 'https://chat.example.org/';
|
||||||
|
const msg = (over: Partial<PushPayload> = {}): PushPayload => ({
|
||||||
|
room_id: '!r:x',
|
||||||
|
event_id: '$e',
|
||||||
|
room_name: 'General',
|
||||||
|
sender: '@a:x',
|
||||||
|
sender_display_name: 'Alice',
|
||||||
|
type: 'm.room.message',
|
||||||
|
content: { msgtype: 'm.text', body: 'hello there' },
|
||||||
|
user_id: '@me:x',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('count-only pushes show nothing', () => {
|
||||||
|
assert.equal(buildPushNotification({ unread: 3 }, SCOPE), null);
|
||||||
|
assert.equal(buildPushNotification({ room_id: '!r:x', unread: 0 }, SCOPE), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a room message: room title, sender and text, room tag, reply action', () => {
|
||||||
|
const n = buildPushNotification(msg(), SCOPE)!;
|
||||||
|
assert.equal(n.title, 'General');
|
||||||
|
assert.equal(n.options.body, 'Alice: hello there');
|
||||||
|
assert.equal(n.options.tag, '!r:x');
|
||||||
|
assert.equal(n.options.icon, 'https://chat.example.org/public/res/lotus.png');
|
||||||
|
assert.deepEqual(n.options.data, {
|
||||||
|
roomId: '!r:x',
|
||||||
|
eventId: '$e',
|
||||||
|
threadId: undefined,
|
||||||
|
userId: '@me:x',
|
||||||
|
});
|
||||||
|
assert.equal(n.options.actions?.[0]?.action, 'reply');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a DM (no room name): sender as title, text as body', () => {
|
||||||
|
const n = buildPushNotification(msg({ room_name: undefined }), SCOPE)!;
|
||||||
|
assert.equal(n.title, 'Alice');
|
||||||
|
assert.equal(n.options.body, 'hello there');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encrypted rooms never show content and offer no reply', () => {
|
||||||
|
const n = buildPushNotification(
|
||||||
|
msg({
|
||||||
|
type: 'm.room.encrypted',
|
||||||
|
content: { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'x' },
|
||||||
|
}),
|
||||||
|
SCOPE,
|
||||||
|
)!;
|
||||||
|
assert.equal(n.title, 'General');
|
||||||
|
assert.equal(n.options.body, 'Alice');
|
||||||
|
assert.equal(n.options.actions, undefined);
|
||||||
|
const dm = buildPushNotification(
|
||||||
|
msg({ type: 'm.room.encrypted', room_name: undefined, content: undefined }),
|
||||||
|
SCOPE,
|
||||||
|
)!;
|
||||||
|
assert.equal(dm.title, 'Alice');
|
||||||
|
assert.equal(dm.options.body, 'New message');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thread replies coalesce per thread', () => {
|
||||||
|
const n = buildPushNotification(
|
||||||
|
msg({
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: 'in thread',
|
||||||
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
SCOPE,
|
||||||
|
)!;
|
||||||
|
assert.equal(n.options.tag, '!r:x:$root');
|
||||||
|
assert.equal(n.options.data.threadId, '$root');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media messages are described, long text is cut', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildPushNotification(msg({ content: { msgtype: 'm.image', body: 'cat.png' } }), SCOPE)!.options
|
||||||
|
.body,
|
||||||
|
'Alice: sent an image',
|
||||||
|
);
|
||||||
|
const long = buildPushNotification(
|
||||||
|
msg({ content: { msgtype: 'm.text', body: 'x'.repeat(500) } }),
|
||||||
|
SCOPE,
|
||||||
|
)!;
|
||||||
|
assert.equal((long.options.body ?? '').length, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invites go to the invites page', () => {
|
||||||
|
const n = buildPushNotification(
|
||||||
|
msg({ type: 'm.room.member', membership: 'invite', user_is_target: true, content: undefined }),
|
||||||
|
SCOPE,
|
||||||
|
)!;
|
||||||
|
assert.equal(n.title, 'Invitation');
|
||||||
|
assert.equal(n.options.body, 'Alice invited you to General');
|
||||||
|
assert.equal(n.options.tag, 'lotus-invites');
|
||||||
|
assert.equal(n.options.data.path, '/inbox/invites/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls ring with their own tag and stay until handled', () => {
|
||||||
|
const n = buildPushNotification(msg({ type: 'org.matrix.msc4075.rtc.notification' }), SCOPE)!;
|
||||||
|
assert.equal(n.options.body, 'Alice started a call');
|
||||||
|
assert.equal(n.options.tag, '!r:x:call');
|
||||||
|
assert.equal(n.options.requireInteraction, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown event types still show a notification', () => {
|
||||||
|
const n = buildPushNotification(msg({ type: 'm.reaction', content: undefined }), SCOPE)!;
|
||||||
|
assert.equal(n.options.body, 'New activity');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefs from the page are validated', () => {
|
||||||
|
assert.deepEqual(parsePushPrefs(null), DEFAULT_PUSH_PREFS);
|
||||||
|
assert.deepEqual(parsePushPrefs({ enabled: 'yes', snoozeUntil: '5', quietHoursEnabled: 1 }), {
|
||||||
|
...DEFAULT_PUSH_PREFS,
|
||||||
|
});
|
||||||
|
assert.equal(parsePushPrefs({ enabled: false }).enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quiet when paused or inside quiet hours', () => {
|
||||||
|
const at = (h: number, m = 0): Date => new Date(2026, 10, 9, h, m);
|
||||||
|
assert.equal(isPushQuiet(DEFAULT_PUSH_PREFS, at(12)), false);
|
||||||
|
assert.equal(isPushQuiet({ ...DEFAULT_PUSH_PREFS, snoozeUntil: at(13).getTime() }, at(12)), true);
|
||||||
|
assert.equal(
|
||||||
|
isPushQuiet({ ...DEFAULT_PUSH_PREFS, snoozeUntil: at(11).getTime() }, at(12)),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const quiet = { ...DEFAULT_PUSH_PREFS, quietHoursEnabled: true };
|
||||||
|
assert.equal(isPushQuiet(quiet, at(23)), true);
|
||||||
|
assert.equal(isPushQuiet(quiet, at(6, 30)), true);
|
||||||
|
assert.equal(isPushQuiet(quiet, at(12)), false);
|
||||||
|
});
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #202] Web Push: pure helpers for the service worker's `push` handler.
|
||||||
|
*
|
||||||
|
* The homeserver sends a push to our gateway (Sygnal, webpush kind), which
|
||||||
|
* relays the Matrix notification to the browser's push service, encrypted to
|
||||||
|
* this browser's subscription. Sygnal's payload is the notification's fields,
|
||||||
|
* flattened: room_id, room_name, event_id, sender, sender_display_name, type,
|
||||||
|
* content (unencrypted rooms only, size-capped), membership, user_is_target,
|
||||||
|
* unread / missed_calls, plus the pusher's `default_payload` (our user_id).
|
||||||
|
*
|
||||||
|
* Kept free of React / jotai so the service worker bundle stays small.
|
||||||
|
*/
|
||||||
|
import { isSnoozeActive } from './app/utils/snooze';
|
||||||
|
import { isWithinTimeWindow } from './app/utils/timeWindow';
|
||||||
|
|
||||||
|
export type PushPayload = {
|
||||||
|
room_id?: string;
|
||||||
|
room_name?: string;
|
||||||
|
room_alias?: string;
|
||||||
|
event_id?: string;
|
||||||
|
sender?: string;
|
||||||
|
sender_display_name?: string;
|
||||||
|
type?: string;
|
||||||
|
content?: Record<string, unknown>;
|
||||||
|
membership?: string;
|
||||||
|
user_is_target?: boolean;
|
||||||
|
unread?: number;
|
||||||
|
missed_calls?: number;
|
||||||
|
/** From the pusher's `default_payload`: the account this push is for. */
|
||||||
|
user_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Notification preferences the page mirrors into the worker. */
|
||||||
|
export type PushPrefs = {
|
||||||
|
/** Settings → Notifications → Desktop notifications. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Pause notifications: epoch ms, `0` = not paused. */
|
||||||
|
snoozeUntil: number;
|
||||||
|
quietHoursEnabled: boolean;
|
||||||
|
quietHoursStart: string;
|
||||||
|
quietHoursEnd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PUSH_PREFS: PushPrefs = {
|
||||||
|
enabled: true,
|
||||||
|
snoozeUntil: 0,
|
||||||
|
quietHoursEnabled: false,
|
||||||
|
quietHoursStart: '22:00',
|
||||||
|
quietHoursEnd: '07:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Accept only well-formed prefs from a page message; fill the rest in. */
|
||||||
|
export function parsePushPrefs(raw: unknown): PushPrefs {
|
||||||
|
const p = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
enabled: typeof p.enabled === 'boolean' ? p.enabled : DEFAULT_PUSH_PREFS.enabled,
|
||||||
|
snoozeUntil: typeof p.snoozeUntil === 'number' ? p.snoozeUntil : 0,
|
||||||
|
quietHoursEnabled: p.quietHoursEnabled === true,
|
||||||
|
quietHoursStart:
|
||||||
|
typeof p.quietHoursStart === 'string'
|
||||||
|
? p.quietHoursStart
|
||||||
|
: DEFAULT_PUSH_PREFS.quietHoursStart,
|
||||||
|
quietHoursEnd:
|
||||||
|
typeof p.quietHoursEnd === 'string' ? p.quietHoursEnd : DEFAULT_PUSH_PREFS.quietHoursEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paused or in quiet hours: the notification still has to be shown (browsers
|
||||||
|
* require a visible notification per push), but silently.
|
||||||
|
*/
|
||||||
|
export function isPushQuiet(prefs: PushPrefs, now: Date): boolean {
|
||||||
|
return (
|
||||||
|
isSnoozeActive(prefs.snoozeUntil, now.getTime()) ||
|
||||||
|
(prefs.quietHoursEnabled && isWithinTimeWindow(prefs.quietHoursStart, prefs.quietHoursEnd, now))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushNotificationData = {
|
||||||
|
roomId?: string;
|
||||||
|
eventId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
userId?: string;
|
||||||
|
/** Set when the target is known without the page (invites). */
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuiltNotification = {
|
||||||
|
title: string;
|
||||||
|
options: NotificationOptions & {
|
||||||
|
data: PushNotificationData;
|
||||||
|
actions?: { action: string; title: string; type?: 'button' | 'text'; placeholder?: string }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CALL_TYPES = new Set([
|
||||||
|
'm.call.invite',
|
||||||
|
'm.rtc.notification',
|
||||||
|
'org.matrix.msc4075.rtc.notification',
|
||||||
|
'org.matrix.msc4075.call.notify',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BODY_MAX = 120;
|
||||||
|
const INVITES_PATH = '/inbox/invites/';
|
||||||
|
|
||||||
|
const str = (v: unknown): string | undefined =>
|
||||||
|
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
|
||||||
|
|
||||||
|
/** One-line description of a message's content; never for encrypted rooms. */
|
||||||
|
function messageText(content: Record<string, unknown> | undefined): string | undefined {
|
||||||
|
if (!content) return undefined;
|
||||||
|
switch (content.msgtype) {
|
||||||
|
case 'm.image':
|
||||||
|
return 'sent an image';
|
||||||
|
case 'm.video':
|
||||||
|
return 'sent a video';
|
||||||
|
case 'm.audio':
|
||||||
|
return 'sent an audio message';
|
||||||
|
case 'm.file':
|
||||||
|
return 'sent a file';
|
||||||
|
case 'm.location':
|
||||||
|
return 'shared a location';
|
||||||
|
default:
|
||||||
|
return str(content.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadOf(content: Record<string, unknown> | undefined): string | undefined {
|
||||||
|
const rel = content?.['m.relates_to'];
|
||||||
|
if (!rel || typeof rel !== 'object') return undefined;
|
||||||
|
const r = rel as Record<string, unknown>;
|
||||||
|
return r.rel_type === 'm.thread' ? str(r.event_id) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification to show for a push, or `null` for a count-only push (no
|
||||||
|
* event). Mirrors the in-app OS notifications (ClientNonUIFeatures): app logo
|
||||||
|
* as icon, the room as the coalescing tag (`room:thread` for threads), no
|
||||||
|
* message text from encrypted rooms (it can't be decrypted here anyway), and
|
||||||
|
* an inline Reply action only for unencrypted rooms.
|
||||||
|
*/
|
||||||
|
export function buildPushNotification(p: PushPayload, scope: string): BuiltNotification | null {
|
||||||
|
const eventId = str(p.event_id);
|
||||||
|
const roomId = str(p.room_id);
|
||||||
|
if (!eventId || !roomId) return null;
|
||||||
|
|
||||||
|
const logo = `${scope.replace(/\/?$/, '/')}public/res/lotus.png`;
|
||||||
|
const who = str(p.sender_display_name) ?? str(p.sender) ?? 'Someone';
|
||||||
|
const room = str(p.room_name) ?? str(p.room_alias);
|
||||||
|
const userId = str(p.user_id);
|
||||||
|
const base = { icon: logo, badge: logo };
|
||||||
|
|
||||||
|
if (p.membership === 'invite' && p.user_is_target) {
|
||||||
|
return {
|
||||||
|
title: 'Invitation',
|
||||||
|
options: {
|
||||||
|
...base,
|
||||||
|
body: room ? `${who} invited you to ${room}` : `${who} invited you`,
|
||||||
|
tag: 'lotus-invites',
|
||||||
|
data: { path: INVITES_PATH, userId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.type && CALL_TYPES.has(p.type)) {
|
||||||
|
return {
|
||||||
|
title: room ?? who,
|
||||||
|
options: {
|
||||||
|
...base,
|
||||||
|
body: room ? `${who} started a call` : 'Incoming call',
|
||||||
|
tag: `${roomId}:call`,
|
||||||
|
requireInteraction: true,
|
||||||
|
data: { roomId, eventId, userId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.type === 'm.room.encrypted') {
|
||||||
|
return {
|
||||||
|
title: room ?? who,
|
||||||
|
options: {
|
||||||
|
...base,
|
||||||
|
body: room ? who : 'New message',
|
||||||
|
tag: roomId,
|
||||||
|
data: { roomId, eventId, userId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.type === 'm.room.message' || p.type === 'm.sticker') {
|
||||||
|
const text = p.type === 'm.sticker' ? 'sent a sticker' : messageText(p.content);
|
||||||
|
const threadId = threadOf(p.content);
|
||||||
|
let body = 'New message';
|
||||||
|
if (text) body = (room ? `${who}: ${text}` : text).slice(0, BODY_MAX);
|
||||||
|
else if (room) body = who;
|
||||||
|
return {
|
||||||
|
title: room ?? who,
|
||||||
|
options: {
|
||||||
|
...base,
|
||||||
|
body,
|
||||||
|
tag: threadId ? `${roomId}:${threadId}` : roomId,
|
||||||
|
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
|
||||||
|
data: { roomId, eventId, threadId, userId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: room ?? who,
|
||||||
|
options: { ...base, body: 'New activity', tag: roomId, data: { roomId, eventId, userId } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache Storage location where the worker keeps the page's prefs. */
|
||||||
|
export const SW_STATE_CACHE = 'lotus-sw-state-v1';
|
||||||
|
export const PUSH_PREFS_KEY = '/__lotus/push-prefs';
|
||||||
|
|
||||||
|
/** A notification click waiting for a freshly opened window to ask for it. */
|
||||||
|
export const PENDING_CLICK_MAX_AGE_MS = 60_000;
|
||||||
@@ -50,6 +50,10 @@ export enum StateEvent {
|
|||||||
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
|
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
|
||||||
// state-key, aggregated with parent-space packs like custom emoji.
|
// state-key, aggregated with parent-space packs like custom emoji.
|
||||||
LotusSoundboardRoom = 'io.lotus.soundboard',
|
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 {
|
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
|
// Lotus ML noise suppression: ship the denoise worklets/wasm/models alongside
|
||||||
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
|
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
|
||||||
// dist/public/element-call (closeBundle order). [Gitea #293] The dev server
|
// 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.mkdirSync(path.dirname(d), { recursive: true });
|
||||||
fs.copyFileSync(s, d);
|
fs.copyFileSync(s, d);
|
||||||
});
|
});
|
||||||
|
dfnWorkletToFile(path.join(denoiseDir, 'deepfilternet/index.esm.js'));
|
||||||
|
|
||||||
// [lotus] DENOISE CUTOVER: the getUserMedia shim is no longer injected.
|
// [lotus] DENOISE CUTOVER: the getUserMedia shim is no longer injected.
|
||||||
// Our forked Element Call now runs ML denoise in-source as a real LiveKit
|
// Our forked Element Call now runs ML denoise in-source as a real LiveKit
|
||||||
|
|||||||
Reference in New Issue
Block a user