Compare commits

..
1 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 7a24b168cb feat(seasonal): Lunar New Year redesign — lantern garland above the header, lighter tint (#231)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 23s
CI / Playwright smoke (e2e) (pull_request) Successful in 30m46s
- The paper lanterns become a garland of small lanterns (gold caps, ribs and
  tassels) on a scalloped gold cord, all within the top ~20px. The old
  lanterns hung 7-20% down, opaque, over Create Room, the members search box
  and messages. One repeating SVG tile; two sets sway and glow alternately.
- The pink lacquer wash is about halved and the dark bottom rim is gone, so
  grey secondary text keeps its contrast on light themes.
- The dragon silhouette is removed: screen-blended and blurred, it was not
  visible on light and barely on dark (open question on the issue).
- Reduced motion: the garland is static; the settled blossoms are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 23:34:06 -04:00
40 changed files with 167 additions and 1471 deletions
+1 -2
View File
@@ -16,8 +16,7 @@
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
"slug": "public",
"noticePrefix": "Matrix:",
"slug": "matrix",
"groups": {
"homeserver": "Homeserver",
"calls": "Voice calls",
-31
View File
@@ -473,37 +473,6 @@ test.describe('local homeserver regression', () => {
await expect(page.getByText(/Status set\. Sharing it with others/)).toHaveCount(0);
});
test('own status shows in the members drawer without switching rooms, and clears', async ({
page,
}) => {
// The SDK creates our own User with `new User()` (no event re-emit), so the
// shared presence store missed our own presence until a remount. Reported:
// a new status only appeared after switching rooms.
await page.setViewportSize({ width: 1440, height: 900 });
const me = await ensureUser(uniq('own'));
const room = await createRoom(me, 'Own Status Room');
await sendText(me, room, 'hello');
await loginUI(page, me);
await openRoom(page, room);
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click();
await page
.getByRole('group', { name: 'Quick statuses' })
.getByRole('button', { name: /Gaming/ })
.click();
await page.keyboard.press('Escape');
await expect(page.getByText('🎮 Gaming')).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click();
await page
.getByRole('button', { name: /^Clear/ })
.first()
.click();
await page.keyboard.press('Escape');
await expect(page.getByText('🎮 Gaming')).toHaveCount(0, { timeout: 15_000 });
});
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
const page = await ctx.newPage();
-72
View File
@@ -1,72 +0,0 @@
import { test, expect } from '@playwright/test';
import {
api,
createRoom,
enc,
ensureUser,
hsReachable,
loginUI,
openRoom,
sendText,
uniq,
} from './localHs';
// Offline outbox (#112): messages sent while offline are queued, then
// delivered once each, in order, when the connection returns. Checked on the
// server, so duplicates or reordering would show.
test.describe('offline outbox', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('queued while offline, delivered once each and in order when back', async ({
page,
context,
}) => {
test.setTimeout(120_000);
const me = await ensureUser(uniq('outbox'));
const room = await createRoom(me, 'Outbox Room');
await sendText(me, room, 'before going offline');
await loginUI(page, me);
await openRoom(page, room);
await expect(page.getByText('before going offline')).toBeVisible();
await context.setOffline(true);
const composer = page.getByRole('textbox', { name: /message/i }).last();
const texts = [uniq('one '), uniq('two '), uniq('three ')];
for (const text of texts) {
// eslint-disable-next-line no-await-in-loop
await composer.fill(text);
// eslint-disable-next-line no-await-in-loop
await composer.press('Enter');
}
for (const text of texts) {
const item = page.locator('[data-message-item]', { hasText: text });
// eslint-disable-next-line no-await-in-loop
await expect(item).toHaveCount(1);
// eslint-disable-next-line no-await-in-loop
await expect(item.getByLabel(/^Queued\. Will send when you're back online/)).toBeVisible({
timeout: 20_000,
});
}
await context.setOffline(false);
const bodiesOnServer = async () => {
const res = await api<{ chunk: { type: string; content?: { body?: string } }[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=f&limit=100`,
me.token,
);
return res.chunk
.filter((e) => e.type === 'm.room.message' && texts.includes(e.content?.body ?? ''))
.map((e) => e.content?.body);
};
await expect.poll(bodiesOnServer, { timeout: 60_000, intervals: [1000] }).toEqual(texts);
for (const text of texts) {
// eslint-disable-next-line no-await-in-loop
await expect(
page.locator('[data-message-item]', { hasText: text }).getByLabel(/^Queued/),
).toHaveCount(0, { timeout: 20_000 });
}
});
});
-102
View File
@@ -1,102 +0,0 @@
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);
});
});
-83
View File
@@ -1,83 +0,0 @@
import { test, expect, Page } from '@playwright/test';
import { api, ensureUser, hsReachable, loginUI, PASSWORD } from './localHs';
// Signing out other devices with SSO confirmation. The desktop app opens the SSO
// page in the system browser (window.open gives no window back), so the dialog
// must still offer "Continue" — before the fix the only way on was starting
// over, in a loop (reported by a desktop SSO user). The server is mocked: the
// retry succeeds only if it carries the challenge's session.
const openDevicesAndSignOutOther = async (page: Page, flows: string[][]) => {
const sent: unknown[] = [];
await page.route('**/delete_devices', async (route) => {
const body = JSON.parse(route.request().postData() ?? '{}');
sent.push(body.auth ?? null);
if (body.auth?.session === 'S1') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
}
return route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
session: 'S1',
flows: flows.map((stages) => ({ stages })),
params: {},
}),
});
});
await page.getByRole('button', { name: 'User Settings' }).click();
const dialog = page.getByRole('dialog').first();
await dialog.getByText('Privacy & security', { exact: true }).first().click();
const card = dialog
.getByText('Old laptop', { exact: true })
.locator('xpath=ancestor::*[.//button][1]');
await card.getByRole('button').nth(1).click(); // the delete chip
const bar = dialog.getByText(/Logout from selected devices/);
await bar
.locator('xpath=ancestor::*[.//button[normalize-space()="Logout"]][1]')
.getByRole('button', { name: 'Logout', exact: true })
.click();
return { sent, bar };
};
test.describe('SSO confirmation for signing out devices', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('desktop: no popup window, Continue still completes it', async ({ page }) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
window.open = () => null; // the desktop app hands the page to the system browser
});
const user = await ensureUser(`ssod${Date.now().toString(36)}`);
await api('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: user.localpart },
password: PASSWORD,
initial_device_display_name: 'Old laptop',
});
await loginUI(page, user);
const { sent, bar } = await openDevicesAndSignOutOther(page, [['m.login.sso']]);
await page.getByRole('button', { name: 'Continue with SSO' }).click();
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await expect(bar).toHaveCount(0);
expect(sent).toEqual([null, { session: 'S1' }]);
});
test('password offered first: SSO is still one click away', async ({ page }) => {
test.setTimeout(120_000);
const user = await ensureUser(`ssop${Date.now().toString(36)}`);
await api('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: user.localpart },
password: PASSWORD,
initial_device_display_name: 'Old laptop',
});
await loginUI(page, user);
await openDevicesAndSignOutOther(page, [['m.login.password'], ['m.login.sso']]);
await expect(page.getByText('Account Password').first()).toBeVisible();
await page.getByRole('button', { name: 'Use SSO instead' }).click();
await expect(page.getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Use your password instead' })).toBeVisible();
});
});
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.28.0-lotus.4",
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.28.0-lotus.4",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.4/element-call-embedded-0.28.0-lotus.4.tgz",
"integrity": "sha512-g+1mkdpGqUkE7JJo3b2G0XBiwDFwEedWul20hwD0iBpDyW67OafjbhrzRwfROY8Q9bx6BnulJHiR6Ll98KjguA==",
"version": "0.28.0-lotus.2",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.2/element-call-embedded-0.28.0-lotus.2.tgz",
"integrity": "sha512-g3xaZ/i3RQ0mToWmjLi08HjSuKD3BpAllj9aEucPS8wh3Uwmz4HXa0tgF1hEN3dPrZR2BTWLQii5Mtfh4037vg==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.28.0-lotus.4",
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+3 -30
View File
@@ -1,5 +1,4 @@
import React, { ReactNode, useState } from 'react';
import { Box, Button, Text } from 'folds';
import React, { ReactNode } from 'react';
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { getUIAFlowForStages } from '../utils/matrix-uia';
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
@@ -24,18 +23,7 @@ type ActionUIAProps = {
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
const mx = useMatrixClient();
const completed = useUIACompleted(authData);
// The server may offer several ways to confirm (e.g. password or SSO). Start
// with the given one but let the user switch: an account that signs in with
// SSO may have no password it knows.
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
const [flow, setFlow] = useState<UIAFlow>(ongoingFlow);
const { getStageToComplete } = useUIAFlow(authData, flow);
const other = supportedFlows.find((f) => f.stages.join() !== flow.stages.join());
const otherLabel = other?.stages.includes(AuthType.Sso)
? 'Use SSO instead'
: other?.stages.includes(AuthType.Password)
? 'Use your password instead'
: undefined;
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
const stageToComplete = getStageToComplete();
@@ -43,7 +31,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
return (
<UIAFlowOverlay
currentStep={completed.length + 1}
stepCount={flow.stages.length}
stepCount={ongoingFlow.stages.length}
onCancel={onCancel}
>
{stageToComplete.type === AuthType.Password && (
@@ -62,21 +50,6 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
submitAuthDict={action}
/>
)}
{other && otherLabel && completed.length === 0 && (
<Box justifyContent="Center" style={{ marginTop: 8 }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setFlow(other)}
>
<Text as="span" size="B300">
{otherLabel}
</Text>
</Button>
</Box>
)}
</UIAFlowOverlay>
);
}
-2
View File
@@ -60,7 +60,6 @@ import { SrOnly } from '../features/call-status/styles.css';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality';
import { useCallDenoise } from '../hooks/useCallDenoise';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
@@ -837,7 +836,6 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useMutedTalkWarning(embed, joined);
useCallThemeSync(embed);
useCallQuality(embed);
useCallDenoise(embed);
useCallHangupEvent(
embed,
useCallback(() => {
-109
View File
@@ -1,109 +0,0 @@
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>
);
}
-1
View File
@@ -1 +0,0 @@
export * from './BridgeBadge';
@@ -9,46 +9,14 @@ import { keyframes } from '@vanilla-extract/css';
* component; this module is motion only.
*/
/**
* Lantern bob — a hung lantern rises a touch and sinks again on a long, lazy
* cycle, as if buoyed by warm air. translateY + a whisper of scale only; the
* per-lantern duration/delay desynchronise the swarm.
*/
export const animLanternBob = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
'50%': { transform: 'translate3d(0, -2.2vh, 0) scale(1.015)' },
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
});
/**
* Lantern pendulum — a gentle rotational sway about the top mount, so each
* lantern rocks like it hangs from a string. Pairs with the bob on a different
* period to read as organic drift rather than a metronome.
*/
export const animLanternSway = keyframes({
'0%': { transform: 'rotate(-2.4deg)' },
'50%': { transform: 'rotate(2.4deg)' },
'100%': { transform: 'rotate(-2.4deg)' },
});
/**
* Tassel sway — the silk tassel under a lantern trails its parent's motion with
* a wider, slightly lagging swing. transformOrigin is the top of the tassel.
*/
export const animTasselSway = keyframes({
'0%': { transform: 'rotate(5deg)' },
'50%': { transform: 'rotate(-5deg)' },
'100%': { transform: 'rotate(5deg)' },
});
/**
* Lantern inner glow — the warm light inside each lantern swells and dims, like
* a candle breathing. Opacity + scale only.
*/
export const animGlowBreathe = keyframes({
'0%': { transform: 'scale(0.94)', opacity: '0.55' },
'50%': { transform: 'scale(1.06)', opacity: '0.9' },
'100%': { transform: 'scale(0.94)', opacity: '0.55' },
'0%': { opacity: '0.7' },
'50%': { opacity: '1' },
'100%': { opacity: '0.7' },
});
/**
@@ -77,16 +45,6 @@ export const animPetalSway = keyframes({
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/**
* Dragon drift — the gold dragon silhouette breathes and undulates almost
* imperceptibly across the scene. translate + scale + opacity only, very slow.
*/
export const animDragonDrift = keyframes({
'0%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
'50%': { transform: 'translate3d(2%, -1%, 0) scale(1.04)', opacity: '0.6' },
'100%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
});
/**
* Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so
* the static base feels alive without distracting motion.
@@ -107,3 +65,13 @@ export const animEmberRise = keyframes({
'80%': { opacity: '0.5' },
'100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' },
});
/**
* Lantern garland sway — the lantern set shifts a pixel or two side to side, as
* if in a breeze. Transform only.
*/
export const animGarlandSway = keyframes({
'0%': { transform: 'translate3d(-1.5px, 0, 0)' },
'50%': { transform: 'translate3d(1.5px, 0, 0)' },
'100%': { transform: 'translate3d(-1.5px, 0, 0)' },
});
@@ -1,13 +1,10 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animLanternBob,
animLanternSway,
animTasselSway,
animGarlandSway,
animGlowBreathe,
animPetalFall,
animPetalSway,
animDragonDrift,
animLacquerPulse,
animEmberRise,
} from './LunarNewYear.css';
@@ -31,46 +28,6 @@ const GOLD_HI = 'oklch(0.92 0.10 92)';
// data-URI so it costs a single GPU-composited layer (no DOM weight). The curve
// is intentionally abstract and very subtle — a calligraphic ribbon-body with a
// suggestion of a head, mane and tail arcing across the upper scene.
const dragonUri = ((): string => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
`<defs>` +
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
`</linearGradient>` +
`</defs>` +
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
// Sinuous body — a thick tapering serpentine ribbon.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='26' opacity='0.5'/>` +
// Inner highlight running along the body for a calligraphic sheen.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='7' opacity='0.7'/>` +
// Head + horn flourish at the leading end.
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
`stroke-width='9' opacity='0.6'/>` +
// Mane / whisker strokes flaring back from the head.
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
`stroke-width='5' opacity='0.45'/>` +
// Tail wisps.
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
`stroke-width='5' opacity='0.45'/>` +
`</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
type Lantern = {
left: number;
top: number;
scale: number;
bobDuration: number;
swayDuration: number;
delay: number;
opacity: number;
};
type Petal = {
left: number;
size: number;
@@ -90,8 +47,6 @@ type Ember = {
delay: number;
};
// A single five-petal plum blossom (gold), inline SVG so each petal sliver is
// one cheap element. Returned as a data-URI background painted on a square.
const blossomUri = ((): string => {
const petals = Array.from({ length: 5 }, (_, i) => {
const a = (i * 72 * Math.PI) / 180;
@@ -107,28 +62,56 @@ const blossomUri = ((): string => {
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
// Paper lanterns strung across the upper third, gently staggered in depth.
const lanterns = useMemo<Lantern[]>(() => {
const slots = [
{ left: 9, top: 7, scale: 1.0 },
{ left: 27, top: 13, scale: 0.82 },
{ left: 46, top: 6, scale: 1.12 },
{ left: 64, top: 15, scale: 0.78 },
{ left: 82, top: 9, scale: 0.95 },
{ left: 92, top: 20, scale: 0.7 },
];
return slots.map((s, i) => ({
left: s.left,
top: s.top,
scale: s.scale,
bobDuration: 7 + rand(i + 1) * 4,
swayDuration: 5.5 + rand(i + 4) * 3,
delay: -rand(i + 7) * 6,
opacity: 0.78 + rand(i + 2) * 0.18,
}));
}, []);
// [Gitea #231] Lantern garland: one 480px SVG tile (four scallops) repeats
// across the width; small lanterns hang at the scallop low points, all within
// the top ~20px.
const TILE_W = 480;
const TILE_H = 20;
const SCALLOP = 120;
const tile = (body: string) =>
`url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='${TILE_W}' height='${TILE_H}' viewBox='0 0 ${TILE_W} ${TILE_H}'>${body}</svg>`,
)}")`;
const CORD = tile(
`<path d='M0 1 Q${SCALLOP / 2} 9 ${SCALLOP} 1 ${Array.from(
{ length: TILE_W / SCALLOP - 1 },
(_, i) => `T${(i + 2) * SCALLOP} 1`,
).join(' ')}' fill='none' stroke='${GOLD}' stroke-width='1.1' stroke-opacity='0.8'/>`,
);
const lanternSvg = (which: 0 | 1) =>
Array.from({ length: TILE_W / SCALLOP }, (_, k) => {
if (k % 2 !== which) return '';
const x = SCALLOP / 2 + k * SCALLOP;
return (
`<ellipse cx='${x}' cy='11.5' rx='9' ry='7' fill='${VERMILION}' opacity='0.28'/>` +
`<rect x='${x - 3}' y='5' width='6' height='1.6' rx='0.6' fill='${GOLD}'/>` +
`<ellipse cx='${x}' cy='11' rx='5.6' ry='4.4' fill='${CRIMSON}' stroke='${GOLD}' stroke-width='0.6'/>` +
`<path d='M${x - 2.4} 7.2 Q${x - 3.4} 11 ${x - 2.4} 14.8 M${x} 6.6 V15.4 M${x + 2.4} 7.2 Q${x + 3.4} 11 ${x + 2.4} 14.8' fill='none' stroke='${GOLD}' stroke-width='0.4' stroke-opacity='0.7'/>` +
`<ellipse cx='${x - 1.6}' cy='9.6' rx='1.6' ry='1.2' fill='${GOLD_HI}' opacity='0.45'/>` +
`<rect x='${x - 2.4}' y='15.2' width='4.8' height='1.4' rx='0.5' fill='${GOLD}'/>` +
`<path d='M${x} 16.6 V19.4' stroke='${GOLD}' stroke-width='0.9' stroke-linecap='round'/>`
);
}).join('');
const LANTERNS_A = tile(lanternSvg(0));
const LANTERNS_B = tile(lanternSvg(1));
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
const garlandLayer = (image: string, animated: boolean, delay = 0): React.CSSProperties => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${TILE_H}px`,
backgroundImage: image,
backgroundRepeat: 'repeat-x',
backgroundSize: `${TILE_W}px ${TILE_H}px`,
backgroundPosition: '-20px 0',
animation:
reduced || !animated
? 'none'
: `${animGarlandSway} 5.5s ease-in-out ${delay}s infinite, ${animGlowBreathe} 3.8s ease-in-out ${delay}s infinite`,
willChange: reduced || !animated ? undefined : 'transform, opacity',
});
// Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
// near large/bright/fast) for depth.
const petals = useMemo<Petal[]>(() => {
@@ -185,216 +168,33 @@ export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0,
contain: 'layout paint style',
backgroundImage: [
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`,
`radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`,
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`,
`radial-gradient(120% 70% at 50% -8%, ${CRIMSON.replace(')', ' / 0.09)')} 0%, transparent 52%)`,
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.035) 0%, transparent 22%)`,
].join(','),
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Imperial-gold dragon silhouette arcing across the upper scene — a
single composited SVG layer, blurred and screen-blended so it reads as
an ethereal gilt apparition, never a hard graphic. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
left: '-6%',
right: '-6%',
height: '46%',
contain: 'layout paint style',
backgroundImage: dragonUri,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundSize: 'contain',
mixBlendMode: 'screen',
filter: 'blur(1.1px)',
opacity: reduced ? 0.52 : undefined,
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
backdrop-filter for a silken haze around the rim. */}
{/* Warm vignette frame — a faint crimson rim, clear center. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backdropFilter: 'blur(0.4px) saturate(1.05)',
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
backgroundImage:
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)',
'radial-gradient(135% 120% at 50% 40%, transparent 60%, oklch(0.55 0.16 28 / 0.05) 100%)',
}}
/>
{/* The garland string the lanterns hang from — a faint warm line. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '6%',
contain: 'layout paint style',
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
')',
' / 0.14)',
)} 0%, transparent 70%)`,
}}
/>
{/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
mount, an inner bob, then the lantern body (glow + ribs + caps) and a
trailing tassel. */}
{lanterns.map((l, i) => {
const W = 30 * l.scale;
const H = 38 * l.scale;
const cap = Math.max(8, W * 0.5);
return (
<div
key={`lantern-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${l.left}%`,
top: `${l.top}%`,
marginLeft: `${-W / 2}px`,
transformOrigin: 'top center',
opacity: l.opacity,
animation: reduced
? 'none'
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
<div
style={{
animation: reduced
? 'none'
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
{/* short cord from the string to the top cap */}
<div
style={{
width: '2px',
height: `${10 * l.scale}px`,
margin: '0 auto',
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
}}
/>
{/* top gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
}}
/>
{/* lantern body */}
<div
style={{
position: 'relative',
width: `${W}px`,
height: `${H}px`,
margin: `${1 * l.scale}px auto`,
borderRadius: '50% / 42%',
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
')',
' / 0.95)',
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
')',
' / 0.5)',
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
overflow: 'hidden',
}}
>
{/* breathing inner candle glow */}
<div
style={{
position: 'absolute',
left: '50%',
top: '52%',
width: `${W * 0.6}px`,
height: `${H * 0.55}px`,
marginLeft: `${-W * 0.3}px`,
marginTop: `${-H * 0.275}px`,
borderRadius: '50%',
background: `radial-gradient(circle, ${GOLD_HI.replace(
')',
' / 0.9)',
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
filter: 'blur(1px)',
animation: reduced
? 'none'
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
l.delay
}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* vertical paper ribs */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
W / 6 - 0.6
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
')',
' / 0.18)',
)} ${W / 6}px)`,
}}
/>
</div>
{/* bottom gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
}}
/>
{/* swaying silk tassel */}
<div
style={{
width: `${2 * l.scale}px`,
height: `${16 * l.scale}px`,
margin: '0 auto',
transformOrigin: 'top center',
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
borderRadius: '1px',
animation: reduced
? 'none'
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
/>
</div>
</div>
);
})}
{/* [Gitea #231] A garland of small paper lanterns on a scalloped cord, all
within the top ~20px so it stays above header icons and text (the old
lanterns hung 7-20% down, over Create Room, search boxes and messages).
Two lantern sets glow and sway a little, alternately. */}
<div aria-hidden="true" style={garlandLayer(CORD, false)} />
<div aria-hidden="true" style={garlandLayer(LANTERNS_A, true, 0)} />
<div aria-hidden="true" style={garlandLayer(LANTERNS_B, true, -2.2)} />
{/* Drifting gold plum-blossom petals (motion only). Static settled
blossoms render below for the reduced/preview scene. */}
+7 -23
View File
@@ -12,12 +12,6 @@ export function SSOStage({
}) {
const { errorCode, error, session } = stageData;
const [ssoWindow, setSSOWindow] = useState<Window>();
// Whether the SSO page was opened. Not the same as having a window handle:
// the desktop app opens it in the system browser and gets none back, and a
// popup can lose its link to us (cross-origin opener policy on the identity
// provider). In both cases "authDone" never arrives, so "Continue" must still
// be offered once the page was opened, or the only way on is starting over.
const [started, setStarted] = useState(false);
const handleSubmit = useCallback(() => {
submitAuthDict({
@@ -28,7 +22,6 @@ export function SSOStage({
const handleContinue = () => {
const w = window.open(ssoRedirectURL, '_blank');
setSSOWindow(w ?? undefined);
setStarted(true);
};
useEffect(() => {
@@ -81,9 +74,7 @@ export function SSOStage({
gap="400"
>
<Text size="T200">
{started
? 'Finish signing in on the page that opened, then come back here and select Continue.'
: 'To do this, confirm it’s you by signing in with SSO.'}
To perform this action you need to authenticate yourself by SSO login.
</Text>
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
@@ -94,19 +85,12 @@ export function SSOStage({
</Box>
)}
{started ? (
<Box direction="Column" gap="200">
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
<Button variant="Secondary" fill="None" onClick={handleContinue}>
<Text as="span" size="B400">
Open the sign-in page again
</Text>
</Button>
</Box>
{ssoWindow ? (
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
) : (
<Button variant="Primary" onClick={handleContinue}>
<Text as="span" size="B400">
@@ -27,7 +27,6 @@ import { stopPropagation } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useLocalTime } from '../../hooks/useLocalTime';
import { UserBridgeBadge } from '../bridge';
type UserHeroProps = {
userId: string;
@@ -144,7 +143,6 @@ export function UserHeroName({
>
{displayName ?? username ?? userId}
</Text>
<UserBridgeBadge userId={userId} size="200" />
</Box>
{pronouns && (
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
-2
View File
@@ -71,7 +71,6 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/use
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
import { UserBridgeBadge } from '../../components/bridge';
import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
@@ -179,7 +178,6 @@ function MemberItem({
{presence && (
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
)}
<UserBridgeBadge userId={member.userId} />
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
{typing && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
-2
View File
@@ -34,7 +34,6 @@ import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { RoomBridgeChip } from '../../components/bridge';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
@@ -691,7 +690,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)}
</TooltipProvider>
)}
<RoomBridgeChip room={room} />
</Box>
{topic && (
<UseStateProvider initial={false}>
@@ -90,7 +90,6 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power';
import { UserBridgeBadge } from '../../../components/bridge';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
@@ -1085,7 +1084,6 @@ export const Message = React.memo(
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
<UserBridgeBadge userId={senderId} />
</Box>
<Box shrink="No" gap="100">
{messageLayout === MessageLayout.Modern && hover && (
@@ -6,7 +6,6 @@ import {
DenoiseNode,
buildGateNode,
buildModelNode,
monoDownmix,
readDb,
sampleRateFor,
} from '../../../utils/denoisePipeline';
@@ -140,7 +139,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
inAnalyser.fftSize = 1024;
source.connect(inAnalyser);
let head: AudioNode = source.connect(monoDownmix(ctx));
let head: AudioNode = source;
let gate: AudioWorkletNode | null = null;
if (useGate) {
gate = await buildGateNode(ctx, gateThreshold);
@@ -309,11 +308,10 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
const source = ctx.createBufferSource();
source.buffer = clip;
const mono = source.connect(monoDownmix(ctx));
let playGate: AudioWorkletNode | null = null;
let playModelNode: DenoiseNode | null = null;
if (playModel) {
let head: AudioNode = mono;
let head: AudioNode = source;
if (useGate) {
playGate = await buildGateNode(ctx, gateThreshold);
head.connect(playGate);
@@ -323,7 +321,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
head.connect(playModelNode.node);
playModelNode.node.connect(ctx.destination);
} else {
mono.connect(ctx.destination);
source.connect(ctx.destination);
}
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
// panel unmounted)? Tear down this now-orphaned graph instead of storing
@@ -2037,7 +2037,7 @@ export function Calls() {
<Text size="L400">Model</Text>
<SettingTile
title="ML model"
description="Choose the machine learning model used for noise removal. 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."
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
after={
<SettingsSelect<DenoiseModelId>
value={callDenoiseModel}
@@ -2064,9 +2064,9 @@ export function Calls() {
<Box direction="Row" gap="400" wrap="Wrap">
{(
[
{ label: 'CPU (one core)', value: selectedDenoiseModel.cpuUsage },
{ label: 'CPU', value: selectedDenoiseModel.cpuUsage },
{ label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
{ label: 'Typing & clicks', value: selectedDenoiseModel.transients },
{ label: 'Transients', value: selectedDenoiseModel.transients },
{ label: 'Download', value: selectedDenoiseModel.binarySize },
] as const
).map((stat) => (
@@ -2113,7 +2113,7 @@ export function Calls() {
</Box>
<Box grow="Yes">
<Text size="T200" priority="300">
Typing & clicks
Transients
</Text>
</Box>
</Box>
@@ -2137,7 +2137,7 @@ export function Calls() {
</details>
<Text size="T200" priority="400">
Changes apply straight away, even during a call.
Note: Applying changes requires rejoining the call.
</Text>
</Box>
-41
View File
@@ -1,41 +0,0 @@
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,
]);
}
-32
View File
@@ -1,32 +0,0 @@
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],
);
};
+1 -1
View File
@@ -62,7 +62,7 @@ export const useServerStatus = (
]);
if (stopped) return;
failures = 0;
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups, page!.noticePrefix);
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
setStatus(last);
// While this client's connection is lost, keep asking every minute: that
// is how it learns Kuma has checked the server since the drop.
+2 -29
View File
@@ -1,12 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import {
ClientEvent,
MatrixClient,
MatrixEvent,
User,
UserEvent,
UserEventHandlerMap,
} from 'matrix-js-sdk';
import { MatrixClient, User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
export enum Presence {
@@ -26,12 +19,7 @@ export type UserPresence = {
const getUserPresence = (user: User): UserPresence => ({
presence: user.presence as Presence,
// From the latest presence event rather than user.presenceStatusMsg: the SDK
// only overwrites that field with a non-empty status_msg, so a cleared status
// would otherwise linger until reload.
status: user.events.presence
? (user.events.presence.getContent().status_msg as string | undefined) || undefined
: user.presenceStatusMsg,
status: user.presenceStatusMsg,
active: user.currentlyActive,
lastActiveTs: user.getLastActiveTs(),
});
@@ -67,19 +55,6 @@ class PresenceStore {
subs.forEach((cb) => cb());
};
// Our own User object is created by the SDK with `new User()` (not
// User.createUser), so its User.* events are never re-emitted on the client
// and the listeners above miss our own presence changes. The client does
// emit every sync presence event as ClientEvent.Event (after updating the
// User), so refresh from that too.
private handleClientEvent = (event: MatrixEvent): void => {
if (event.getType() !== 'm.presence' || !this.mx) return;
const userId = event.getSender();
if (!userId) return;
const user = this.mx.getUser(userId);
if (user) this.handleEvent(event, user);
};
private start(mx: MatrixClient): void {
if (this.started && this.mx === mx) return;
// A different client (re-login without reload) — drop the old listeners.
@@ -87,14 +62,12 @@ class PresenceStore {
this.mx.removeListener(UserEvent.Presence, this.handleEvent);
this.mx.removeListener(UserEvent.CurrentlyActive, this.handleEvent);
this.mx.removeListener(UserEvent.LastPresenceTs, this.handleEvent);
this.mx.removeListener(ClientEvent.Event, this.handleClientEvent);
this.cache.clear();
}
this.mx = mx;
mx.on(UserEvent.Presence, this.handleEvent);
mx.on(UserEvent.CurrentlyActive, this.handleEvent);
mx.on(UserEvent.LastPresenceTs, this.handleEvent);
mx.on(ClientEvent.Event, this.handleClientEvent);
this.started = true;
}
-23
View File
@@ -4,7 +4,6 @@ import { CallControlState } from './CallControlState';
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
import { canDelegateCapability } from './utils';
import { CallErrorState, parseCallError } from './callError';
import type { LotusDenoisePayload } from '../../utils/callDenoise';
export enum CallControlEvent {
StateUpdate = 'state_update',
@@ -107,11 +106,6 @@ export class CallControl extends EventEmitter implements CallControlState {
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
private lastQuality: LotusQualityPayload | null = null;
// Last noise-suppression choice (io.lotus.set_denoise), held like
// lastQuality: sent once joined and re-sent after an EC reconnect, whose
// remounted handler starts again from the settings in the join URL.
private lastDenoise: LotusDenoisePayload | null = null;
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
// user-initiated unmute that auto-undeafens the user.
@@ -197,7 +191,6 @@ export class CallControl extends EventEmitter implements CallControlState {
this.joined = true;
this.sendDeafenState();
this.sendQuality();
this.sendDenoise();
this.sendHotkeyCodes();
this.sendFrameScreenshareAllowed();
}
@@ -211,7 +204,6 @@ export class CallControl extends EventEmitter implements CallControlState {
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
this.sendDenoise();
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
@@ -770,21 +762,6 @@ export class CallControl extends EventEmitter implements CallControlState {
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
}
/**
* Switch noise suppression on the live mic (the fork's `io.lotus.set_denoise`):
* browser-native on/off and/or the ML model, without rejoining. Join-gated
* like setQuality().
*/
public setDenoise(payload: LotusDenoisePayload): void {
this.lastDenoise = payload;
this.sendDenoise();
}
private sendDenoise(): void {
if (!this.joined || !this.lastDenoise) return;
this.call.transport.send('io.lotus.set_denoise', this.lastDenoise).catch(() => undefined);
}
public dispose() {
// [Gitea #56] Don't let a manual focus pin outlive the call.
this.clearFocusParticipant();
+7 -34
View File
@@ -133,15 +133,6 @@ export class CallEmbed {
private themeKind: ElementCallThemeKind = 'dark';
// The theme the frame was loaded with (its URL `theme` param), and whether
// the widget API is ready. Until it is, the iframe may still be on its
// initial about:blank (our own origin), so a send to the call's origin is
// refused ("target origin … does not match the recipient window's origin")
// and the request hangs to a timeout. Theme changes wait for ready.
private loadedThemeKind: ElementCallThemeKind = 'dark';
private widgetReady = false;
// Watchdog: detects an iframe that never reaches a usable state.
private loadWatchdog?: ReturnType<typeof setTimeout>;
@@ -237,14 +228,10 @@ export class CallEmbed {
denoiseMode === 'browser' ||
(denoiseMode === 'ml' && denoiseNativeNS)
).toString(),
// Keep the browser's auto gain control ON in every tier. It used to be
// turned off for the ML tier ("pumping"), but AGC is what brings a quiet
// mic up to speaking level: without it ML-tier voices went out 10-20 dB
// quieter than the browser tier, while the models themselves keep speech
// within ~1.5 dB (measured: RNNoise, Speex, DTLN, DeepFilterNet3). Chrome's
// AGC2 only adapts during speech, and the model runs after it, so it still
// removes the noise AGC leaves in the pauses.
autoGainControl: 'true',
// Turn the browser's auto gain control OFF for the ML tier only: its
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
// tiers keep the browser's normal capture pipeline (AGC on).
autoGainControl: (denoiseMode !== 'ml').toString(),
audio: initialAudio.toString(),
video: initialVideo.toString(),
header: 'none',
@@ -373,7 +360,6 @@ export class CallEmbed {
this.iframe = iframe;
this.container = container;
this.themeKind = themeKind;
this.loadedThemeKind = themeKind;
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
@@ -389,13 +375,6 @@ export class CallEmbed {
// happy path (onCallJoined) clears it too; these cover earlier signals so a
// user sitting in the lobby/prescreen isn't flagged as an error.
this.disposables.push(this.onReady(() => this.settleLoad()));
this.disposables.push(
this.onReady(() => {
this.widgetReady = true;
// A theme change made while the frame was loading.
if (this.themeKind !== this.loadedThemeKind) this.sendTheme();
}),
);
this.disposables.push(this.onCapabilitiesNotified(() => this.settleLoad()));
this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));
@@ -420,18 +399,12 @@ export class CallEmbed {
return this.room.roomId;
}
public setTheme(theme: ElementCallThemeKind): Promise<unknown> {
public setTheme(theme: ElementCallThemeKind) {
this.themeKind = theme;
// Before ready, the URL theme applies and the change is sent on ready.
if (!this.widgetReady) return Promise.resolve();
return this.sendTheme();
}
private sendTheme(): Promise<unknown> {
return this.call.transport
.send(WidgetApiToWidgetAction.ThemeChange, { name: this.themeKind })
.send(WidgetApiToWidgetAction.ThemeChange, { name: theme })
.catch(() => {
// The frame went away mid-send; nothing to do.
// Widget transport not ready yet; theme URL param is the fallback
});
}
+2 -4
View File
@@ -377,11 +377,9 @@ const defaultSettings: Settings = {
cameraOnJoin: false,
// Tier default stays browser-native (known-good; best-perceived in testing so
// far). If a user opts into the ML tier, default to DTLN: in our measurements
// it keeps voices the most natural, removes fans and typing well, and costs a
// third of DeepFilterNet's CPU.
// far). If a user opts into the ML tier, default to the highest-quality model.
callNoiseSuppression: 'browser',
callDenoiseModel: 'dtln',
callDenoiseModel: 'deepfilternet',
// "Series suppression" (stack the browser's native NS before the ML model) is
// off by default — best practice is a single NS stage; it's an opt-in test aid.
callDenoiseNativeNS: false,
-86
View File
@@ -1,86 +0,0 @@
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');
});
-80
View File
@@ -1,80 +0,0 @@
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}`;
};
-27
View File
@@ -1,27 +0,0 @@
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,
});
});
-31
View File
@@ -1,31 +0,0 @@
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,
});
+1 -23
View File
@@ -21,20 +21,6 @@ const BASE = `${import.meta.env.BASE_URL.replace(/\/+$/, '')}/public/element-cal
*/
export const sampleRateFor = (model: DenoiseModelId): number => (model === 'dtln' ? 16000 : 48000);
/**
* A pass-through node that downmixes whatever feeds it to ONE channel. Every
* model only processes channel 0, and a recorded clip often decodes as stereo
* (Chrome's Opus/WebM), so without this RNNoise/Speex played the cleaned audio
* in the left ear only. Lotus Call does the same (#24). A mono output is then
* upmixed to both ears by the destination.
*/
export const monoDownmix = (ctx: BaseAudioContext): GainNode =>
new GainNode(ctx, {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
});
export type DenoiseNode = {
node: AudioWorkletNode;
dispose: () => void;
@@ -99,10 +85,7 @@ export async function buildModelNode(
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
const core = new mod.DeepFilterNet3Core({
sampleRate: sampleRateFor(model),
// 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,
noiseReductionLevel: 80,
assetConfig: { cdnUrl: dfnBase },
});
await core.initialize();
@@ -122,8 +105,6 @@ export async function buildModelNode(
const [, wasmBinary] = await Promise.all([addModuleOnce(ctx, cfg.script), fetchWasm(cfg.wasm)]);
const node = new AudioWorkletNode(ctx, cfg.proc, {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
numberOfInputs: 1,
numberOfOutputs: 1,
processorOptions: { maxChannels: 1, wasmBinary },
@@ -146,9 +127,6 @@ export async function buildGateNode(
): Promise<AudioWorkletNode> {
await addModuleOnce(ctx, 'noiseGateWorklet.js');
return new AudioWorkletNode(ctx, '@sapphi-red/web-noise-suppressor/noise-gate', {
channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
processorOptions: {
openThreshold: thresholdDb,
closeThreshold: thresholdDb - 5,
-72
View File
@@ -285,75 +285,3 @@ test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)',
'Read the guide docs',
);
});
test('[noticePrefix] resolveStatusPage keeps it only when set', () => {
const base = { url: 'https://kuma.example', slug: 'public' };
assert.equal(
resolveStatusPage({ hs: { ...base, noticePrefix: ' Matrix: ' } }, 'hs')?.noticePrefix,
'Matrix:',
);
assert.equal(resolveStatusPage({ hs: base }, 'hs')?.noticePrefix, undefined);
});
test('[noticePrefix] on a shared page only prefixed notices reach users, without the prefix', () => {
const page = {
publicGroupList: [],
maintenanceList: [
{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' },
{ id: 2, title: 'matrix: Synapse upgrade', status: 'under-maintenance' },
],
incidents: [
{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' },
{ id: 4, title: 'Matrix: Calls are flaky', content: 'y', style: 'warning' },
{ id: 5, title: 'Matrix:', content: 'nothing left after the prefix', style: 'info' },
{ id: 6, title: 'Matrixish news', content: 'z', style: 'info' },
],
};
const s = parseKumaStatus(page, {}, DEFAULT_GROUPS, 'Matrix:');
assert.deepEqual(
s.maintenance.map((m) => [m.id, m.title]),
[['2', 'Synapse upgrade']],
);
assert.deepEqual(
s.incidents.map((i) => [i.id, i.title]),
[['4', 'Calls are flaky']],
);
});
test('[noticePrefix] without one, every notice shows as before', () => {
const page = {
publicGroupList: [],
maintenanceList: [{ id: 1, title: 'Jellyfin upgrade', status: 'under-maintenance' }],
incidents: [{ id: 3, title: 'Storage redundancy restored', content: 'x', style: 'info' }],
};
const s = parseKumaStatus(page, {}, DEFAULT_GROUPS);
assert.equal(s.maintenance[0].title, 'Jellyfin upgrade');
assert.equal(s.incidents[0].title, 'Storage redundancy restored');
});
test('[split public page] a Discord group going down touches neither homeserver nor calls', () => {
const up = [
{ status: 1, time: '2026-11-05 01:00:00' },
{ status: 1, time: '2026-11-05 01:01:00' },
];
const down = [
{ status: 0, time: '2026-11-05 01:00:00' },
{ status: 0, time: '2026-11-05 01:01:00' },
];
const page = {
publicGroupList: [
{ name: 'Homeserver', monitorList: [{ id: 30 }] },
{ name: 'Voice calls', monitorList: [{ id: 37 }] },
{ name: 'Discord', monitorList: [{ id: 49 }] },
{ name: 'Services', monitorList: [{ id: 27 }] },
],
};
const s = parseKumaStatus(
page,
{ heartbeatList: { 30: up, 37: up, 49: down, 27: down } },
DEFAULT_GROUPS,
);
assert.equal(s.homeserver, 'up');
assert.equal(s.calls, 'up');
assert.equal(s.login, 'unknown');
});
+5 -28
View File
@@ -7,11 +7,6 @@
* "login": "Login" } } }
* Users of any other homeserver never contact Kuma.
*
* Optional "noticePrefix": on a status page shared with other services, only
* incidents and maintenance titled with this prefix (e.g. "Matrix:") reach
* Lotus users, shown without it, so a media server's maintenance doesn't say
* messages may be interrupted. Without it, every notice on the page shows.
*
* Kuma 2.x public JSON:
* - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}],
* maintenanceList (only windows UNDER maintenance right now), incidents
@@ -24,12 +19,7 @@
*/
export type StatusGroups = { homeserver?: string; calls?: string; login?: string };
export type StatusPageConfig = {
url: string;
slug: string;
groups: StatusGroups;
noticePrefix?: string;
};
export type StatusPageConfig = { url: string; slug: string; groups: StatusGroups };
export type ServiceState = 'up' | 'down' | 'unknown';
export type KumaMaintenance = { id: string; title: string; description: string; end?: number };
@@ -79,11 +69,9 @@ export const resolveStatusPage = (
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
const g = obj(entry.groups) ?? {};
const noticePrefix = str(entry.noticePrefix).trim();
return {
url: url.origin,
slug,
...(noticePrefix ? { noticePrefix } : {}),
groups: {
homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver,
calls: str(g.calls) || DEFAULT_GROUPS.calls,
@@ -145,18 +133,10 @@ const groupState = (
return known > 0 ? 'up' : 'unknown';
};
/** The title without the prefix, or undefined when it doesn't carry it. */
const noticeTitle = (title: string, prefix: string | undefined): string | undefined => {
if (!prefix) return title;
if (title.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return undefined;
return title.slice(prefix.length).trim() || undefined;
};
export const parseKumaStatus = (
pageJson: unknown,
heartbeatJson: unknown,
groupNames: StatusGroups,
noticePrefix?: string,
): ServerStatus => {
const page = obj(pageJson) ?? {};
const groups = new Map<string, string[]>();
@@ -175,14 +155,14 @@ export const parseKumaStatus = (
Array.isArray(page.maintenanceList) ? page.maintenanceList : []
)
.map((m) => obj(m))
.filter((m): m is Record<string, unknown> => !!m && !!noticeTitle(str(m.title), noticePrefix))
.filter((m): m is Record<string, unknown> => !!m && str(m.title) !== '')
.filter((m) => !m.status || m.status === 'under-maintenance')
.map((m) => {
const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]);
const end = Date.parse(str(slot?.endDate));
return {
id: String(m.id ?? str(m.title)),
title: noticeTitle(str(m.title), noticePrefix) ?? '',
title: str(m.title),
description: str(m.description),
...(Number.isFinite(end) ? { end } : {}),
};
@@ -190,13 +170,10 @@ export const parseKumaStatus = (
const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : [])
.map((i) => obj(i))
.filter(
(i): i is Record<string, unknown> =>
!!i && !!noticeTitle(str(i.title), noticePrefix) && i.active !== false,
)
.filter((i): i is Record<string, unknown> => !!i && str(i.title) !== '' && i.active !== false)
.map((i) => ({
id: String(i.id ?? str(i.title)),
title: noticeTitle(str(i.title), noticePrefix) ?? '',
title: str(i.title),
content: str(i.content),
style: str(i.style) || 'info',
updated: str(i.lastUpdatedDate) || str(i.createdDate),
+2 -2
View File
@@ -5,10 +5,10 @@ import { DENOISE_MODELS, ML_DENOISE_REQUIREMENTS, isMLDenoiseSupported } from '.
// ── Model catalog (data integrity) ──────────────────────────────────────────
test('DENOISE_MODELS lists the four models most-recommended first', () => {
test('DENOISE_MODELS lists the four models ordered best-quality (highest CPU) first', () => {
assert.deepEqual(
DENOISE_MODELS.map((m) => m.id),
['dtln', 'deepfilternet', 'rnnoise', 'speex'],
['deepfilternet', 'dtln', 'rnnoise', 'speex'],
);
});
+20 -26
View File
@@ -1,18 +1,12 @@
/**
* Detection utilities + model catalog for Lotus ML noise suppression
* (DTLN / DeepFilterNet 3 / RNNoise / Speex). The catalog is ordered by
* recommendation — most recommended first — and drives the order of the model
* dropdown in settings.
* (DeepFilterNet 3 / DTLN / RNNoise / Speex). The catalog is ordered by
* quality (and, correspondingly, CPU cost) — highest first — and drives the
* order of the model dropdown in settings.
*/
import { DenoiseModelId } from '../state/settings';
// CPU = share of ONE core while in a call, measured with each model running
// exactly as Lotus Call runs it (Ryzen 7 PRO 4750GE, Chromium); download = bytes
// actually fetched on first use. Re-measure when a model or its settings change.
// Ratings come from scoring each model against the clean voice (PESQ/STOI) on
// real speech mixed with fan, keyboard, street and babble noise at SNR 5 and
// 15 dB; `transients` is how well it removes typing and clicks.
export type DenoiseModel = {
id: DenoiseModelId;
name: string;
@@ -23,25 +17,25 @@ export type DenoiseModel = {
voiceQuality: 'Moderate' | 'High' | 'Very High';
};
// Ordered most-recommended first — this is the dropdown order.
// Ordered best-quality (highest CPU) first — this is the dropdown order.
export const DENOISE_MODELS: DenoiseModel[] = [
{
id: 'dtln',
name: 'DTLN (recommended)',
id: 'deepfilternet',
name: 'DeepFilterNet 3 (beta)',
description:
'Keeps voices the most natural of the four. Removes fans and typing well, but not traffic or people talking in the background.',
cpuUsage: '~9%',
binarySize: '~20 MB',
'Studio-grade deep-learning model (48 kHz fullband, ONNX). Best quality; highest CPU and a larger one-time download.',
cpuUsage: '25-50%',
binarySize: '~18 MB',
transients: 'Excellent',
voiceQuality: 'Very High',
},
{
id: 'deepfilternet',
name: 'DeepFilterNet 3 (strongest)',
id: 'dtln',
name: 'DTLN (beta)',
description:
'Removes the most noise, including loud typing and traffic, but voices can sound slightly muffled. Uses about three times the CPU of DTLN.',
cpuUsage: '~30%',
binarySize: '~18 MB',
'Dual-signal deep-learning model (16 kHz). Strong on transient noise; moderate CPU.',
cpuUsage: '10-20%',
binarySize: '~4 MB',
transients: 'Excellent',
voiceQuality: 'High',
},
@@ -49,18 +43,18 @@ export const DENOISE_MODELS: DenoiseModel[] = [
id: 'rnnoise',
name: 'RNNoise',
description:
'Very light. Handles steady noise like fans and traffic; does little for typing and can sound slightly processed.',
cpuUsage: '~3%',
'Lightweight hybrid model (48 kHz). Very low CPU; good for steady noise like fans, but can sound processed at full strength.',
cpuUsage: '< 5%',
binarySize: '< 1 MB',
transients: 'Poor',
transients: 'Good',
voiceQuality: 'Moderate',
},
{
id: 'speex',
name: 'Speex (legacy)',
name: 'Speex (Legacy)',
description:
'Classic suppressor kept for very slow computers. Barely reduces noise in our tests; prefer any other option.',
cpuUsage: '~2%',
'Classic DSP noise suppressor. Minimal CPU, gentlest on voice; weakest suppression.',
cpuUsage: '< 2%',
binarySize: '< 1 MB',
transients: 'Poor',
voiceQuality: 'Moderate',
-40
View File
@@ -191,43 +191,3 @@ test('[#275] the server limit maps to friendly text; other errors keep theirs',
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
});
test('withRateLimitRetry: waits out a 429 then succeeds; other errors and over-budget waits throw', async () => {
const { withRateLimitRetry } = await import('./scheduledMessages');
const { MatrixError } = await import('matrix-js-sdk');
const limited = () => new MatrixError({ errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1500 }, 429);
const sleeps: number[] = [];
const sleepFn = async (ms: number) => {
sleeps.push(ms);
};
let calls = 0;
const ok = await withRateLimitRetry(
async () => {
calls += 1;
if (calls < 3) throw limited();
return 'done';
},
{ sleepFn },
);
assert.equal(ok, 'done');
assert.deepEqual(sleeps, [1500, 1500]);
await assert.rejects(
withRateLimitRetry(
async () => {
throw new MatrixError({ errcode: 'M_FORBIDDEN' }, 403);
},
{ sleepFn },
),
/M_FORBIDDEN|403/,
);
await assert.rejects(
withRateLimitRetry(
async () => {
throw limited();
},
{ sleepFn, maxWaitMs: 2000 },
),
);
});
+29 -73
View File
@@ -1,4 +1,4 @@
import { IContent, MatrixClient, MatrixError, Method } from 'matrix-js-sdk';
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
import type { ScheduledMessage } from '../state/scheduledMessages';
/**
@@ -17,40 +17,6 @@ export const describeScheduleError = (err: unknown): string => {
return err instanceof Error ? err.message : 'Failed to schedule message.';
};
/**
* Synapse rate-limits delayed-event management per user (listing, send now,
* cancel, restart all count). A quick "Send now" then "Cancel" could get a 429
* and show "Could not cancel" although nothing was wrong. Wait out the
* Retry-After (bounded) and try again. Retrying is safe: a 429 means the
* request was not processed, and scheduling reuses its txnId.
*/
export async function withRateLimitRetry<T>(
fn: () => Promise<T>,
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
): Promise<T> {
const maxWaitMs = opts.maxWaitMs ?? 15_000;
const sleepFn =
opts.sleepFn ??
((ms: number) =>
new Promise<void>((r) => {
setTimeout(r, ms);
}));
let waited = 0;
for (;;) {
try {
// eslint-disable-next-line no-await-in-loop
return await fn();
} catch (e) {
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
const wait = Math.min(Math.max(e.getRetryAfterMs() ?? 1000, 200), 10_000);
if (waited + wait > maxWaitMs) throw e;
waited += wait;
// eslint-disable-next-line no-await-in-loop
await sleepFn(wait);
}
}
}
/**
* Schedule a message via MSC4140 (delayed messages).
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
@@ -76,15 +42,13 @@ export async function scheduleMessage(
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
// Use standard v3 prefix with the MSC4140 delay query param.
const res = (await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Put,
path,
{
'org.matrix.msc4140.delay': delayMs,
},
content,
),
const res = (await mx.http.authedRequest(
Method.Put,
path,
{
'org.matrix.msc4140.delay': delayMs,
},
content,
)) as { delay_id: string };
return res.delay_id;
}
@@ -94,27 +58,23 @@ export async function scheduleMessage(
*/
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'cancel' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'cancel' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
);
}
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'restart' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'restart' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
);
}
@@ -125,14 +85,12 @@ export async function restartScheduledMessage(mx: MatrixClient, delayId: string)
*/
export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await withRateLimitRetry(() =>
mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
),
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
);
}
@@ -180,11 +138,9 @@ export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
/** The user's pending scheduled messages, as the server has them. */
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
const res = await withRateLimitRetry(() =>
mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
}),
);
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
});
return parseDelayedEvents(res);
}
-4
View File
@@ -50,10 +50,6 @@ export enum StateEvent {
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
// state-key, aggregated with parent-space packs like custom emoji.
LotusSoundboardRoom = 'io.lotus.soundboard',
// MSC2346 bridge info; state_key identifies the bridged channel. Bridges
// still send the pre-stable uk.half-shot.bridge copy alongside it.
Bridge = 'm.bridge',
BridgeUnstable = 'uk.half-shot.bridge',
}
export enum MessageEvent {
-31
View File
@@ -136,36 +136,6 @@ function denoiseSources() {
};
}
// deepfilternet3-noise-filter inlines its AudioWorklet as a string and loads it
// from a blob: URL. Audio worklets fall under script-src, and the desktop app's
// CSP (rightly) has no blob: there, so DeepFilterNet 3 failed to start ("denoise
// setProcessor failed … Failed to load worklet module script: blob:…"). Write
// the worklet out as a real file next to the module and load it by URL instead,
// which script-src 'self' allows everywhere. Fails the build if the library's
// shape changes, rather than shipping a model that cannot start.
const DFN_WORKLET_FILE = 'dfn-worklet.js';
const DFN_BLOB_LOAD = ` const blob = new Blob([workletCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
await audioContext.audioWorklet.addModule(blobUrl);`;
function dfnWorkletToFile(esmPath) {
const src = fs.readFileSync(esmPath, 'utf8');
const literal = src.match(/^var workletCode = (".*");\s*$/m)?.[1];
const code = literal ? JSON.parse(literal) : undefined;
if (!code?.includes('registerProcessor(') || !src.includes(DFN_BLOB_LOAD)) {
throw new Error(
`[lotus-denoise] deepfilternet3-noise-filter changed shape (inline worklet / blob loader not found in ${esmPath}) — build aborted; update dfnWorkletToFile in vite.config.js.`,
);
}
fs.writeFileSync(path.join(path.dirname(esmPath), DFN_WORKLET_FILE), code);
fs.writeFileSync(
esmPath,
src.replace(
DFN_BLOB_LOAD,
` // [lotus] Loaded from a file, not a blob: URL (CSP script-src 'self').\n await audioContext.audioWorklet.addModule(new URL('./${DFN_WORKLET_FILE}', import.meta.url).href);`,
),
);
}
// Lotus ML noise suppression: ship the denoise worklets/wasm/models alongside
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
// dist/public/element-call (closeBundle order). [Gitea #293] The dev server
@@ -242,7 +212,6 @@ function lotusDenoise() {
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d);
});
dfnWorkletToFile(path.join(denoiseDir, 'deepfilternet/index.esm.js'));
// [lotus] DENOISE CUTOVER: the getUserMedia shim is no longer injected.
// Our forked Element Call now runs ML denoise in-source as a real LiveKit