Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 cd760ebc3c fix(dev): ML noise suppression works on npm start; no lotusTheme warning per call (#293)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m16s
The denoise worklets/wasm/models were only copied into dist/ by the
lotus-denoise closeBundle hook, so on the dev server the call frame got
the SPA fallback for them and silently used the raw mic. The asset list
is now shared (denoiseSources) between that copy and a dev-server
middleware serving the same paths. Also send lotusTheme=true with
lotusTransparent, which the fork warned about in every call.

Fixes #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:24:03 -04:00
37 changed files with 280 additions and 1059 deletions
-46
View File
@@ -55,52 +55,6 @@ test.describe('local homeserver regression', () => {
expect(console_.unexpected()).toEqual([]); expect(console_.unexpected()).toEqual([]);
}); });
test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
const console_ = collectConsole(page);
const room = await createRoom(alice, 'Poll Room');
await loginUI(page, alice);
await openRoom(page, room);
const question = uniq('Lunch? ');
await page.getByRole('button', { name: 'Create poll' }).first().click();
await page.getByPlaceholder('Ask a question…').fill(question);
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
await page.getByRole('button', { name: 'Create Poll' }).last().click();
const poll = page.locator('[data-message-id]', { hasText: question }).last();
await expect(poll).toBeVisible();
type Ev = {
type: string;
event_id: string;
content: { 'm.relates_to'?: { event_id?: string } };
};
const events = async () =>
(
await api<{ chunk: Ev[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
alice.token,
)
).chunk;
// Vote once the poll has reached the server (its row may still carry the local id).
await expect
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
.toBe(true);
await poll.getByText('Tacos', { exact: true }).first().click();
await expect
.poll(async () => {
const chunk = await events();
const start = chunk.find((e) => /poll\.start/.test(e.type));
return chunk.some(
(e) =>
/poll\.response/.test(e.type) &&
e.content['m.relates_to']?.event_id === start?.event_id &&
start?.event_id.startsWith('$'),
);
})
.toBe(true);
expect(console_.unexpected()).toEqual([]);
});
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => { test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
const room = await createRoom(alice, 'Scroll Room'); const room = await createRoom(alice, 'Scroll Room');
await Array.from({ length: 40 }).reduce<Promise<unknown>>( await Array.from({ length: 40 }).reduce<Promise<unknown>>(
+6 -24
View File
@@ -73,7 +73,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences'; import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room'; import { getStateEvent, getMemberName } from '../utils/room';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing'; import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
@@ -111,8 +111,6 @@ type IncomingCallInfo = {
intent?: string; intent?: string;
notificationType: RTCNotificationType; notificationType: RTCNotificationType;
refEventId: string; refEventId: string;
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
notificationEventId?: string;
}; };
type IncomingCallProps = { type IncomingCallProps = {
dm: boolean; dm: boolean;
@@ -285,9 +283,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
fill="Soft" fill="Soft"
size="400" size="400"
radii="400" radii="400"
onClick={() => onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
}
before={<Icon size="200" src={Icons.Cross} filled />} before={<Icon size="200" src={Icons.Cross} filled />}
> >
<Text as="span" size="B400"> <Text as="span" size="B400">
@@ -459,9 +455,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
size="300" size="300"
radii="300" radii="300"
outlined outlined
onClick={() => onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
}
before={<Icon size="100" src={Icons.Cross} filled />} before={<Icon size="100" src={Icons.Cross} filled />}
> >
<Text as="span" size="B300"> <Text as="span" size="B300">
@@ -502,7 +496,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => { async (event, room, toStartOfTimeline, removed, data) => {
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// only process rtc notification reference events. // only process rtc notification reference events.
// we do not want to wait to decrypt all events. // we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return; if (event.getRelation()?.rel_type !== RelationType.Reference) return;
@@ -603,7 +596,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
: undefined, : undefined,
notificationType, notificationType,
refEventId, refEventId,
notificationEventId: event.getId(),
}; };
// [Gitea #312] Don't ring for a call that is already over or handled: after // [Gitea #312] Don't ring for a call that is already over or handled: after
@@ -612,10 +604,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// stale RTC session). The caller's membership can arrive just after the // stale RTC session). The caller's membership can arrive just after the
// ring, so an absent one gets a short grace period before giving up. // ring, so an absent one gets a short grace period before giving up.
const myUserId = mx.getSafeUserId(); const myUserId = mx.getSafeUserId();
if ( if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
declinedRing(room, [refEventId, event.getId()], myUserId) ||
hasActiveCallMembership(room, myUserId)
) {
return; return;
} }
if (hasActiveCallMembership(room, sender)) { if (hasActiveCallMembership(room, sender)) {
@@ -624,10 +613,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
} }
setTimeout(() => { setTimeout(() => {
if (Date.now() >= senderTs + lifetime) return; if (Date.now() >= senderTs + lifetime) return;
if ( if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
declinedRing(room, [refEventId, event.getId()], myUserId) ||
hasActiveCallMembership(room, myUserId)
) {
return; return;
} }
if (hasActiveCallMembership(room, sender)) setCallInfo(info); if (hasActiveCallMembership(room, sender)) setCallInfo(info);
@@ -697,13 +683,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
data, data,
) => { ) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) { if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
const related = event.getRelation()?.event_id; const related = event.getRelation()?.event_id;
// [Gitea #313] Correct clients reference the notification; older Lotus if (!related || related === refEventId) dismiss(); // declined elsewhere
// builds referenced the call membership. Accept both.
if (!related || related === refEventId || related === callInfo.notificationEventId)
dismiss(); // declined elsewhere
} }
}; };
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships); session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
+10 -56
View File
@@ -26,10 +26,6 @@ import { useModalStyle } from '../hooks/useModalStyle';
export function CloseBehaviorPrompt() { export function CloseBehaviorPrompt() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [launchOnLogin, setLaunchOnLogin] = useState(false); const [launchOnLogin, setLaunchOnLogin] = useState(false);
// [cinny-desktop #37] Stock GNOME has no tray host: "keep running in the
// tray" would hide the window with no icon to bring it back. Older desktop
// builds lack the command; assume a tray then (the old behaviour).
const [hasTray, setHasTray] = useState(true);
const modalStyle = useModalStyle(420); const modalStyle = useModalStyle(420);
// Tells the native side we're listening (see get_close_behavior). // Tells the native side we're listening (see get_close_behavior).
@@ -38,17 +34,7 @@ export function CloseBehaviorPrompt() {
tauriInvoke()?.('get_close_behavior').catch(() => undefined); tauriInvoke()?.('get_close_behavior').catch(() => undefined);
}, []); }, []);
useTauriEvent('lotus-close-requested', () => { useTauriEvent('lotus-close-requested', () => setOpen(true));
const ask = tauriInvoke()?.('has_tray_host');
if (!ask) {
setOpen(true);
return;
}
ask
.then((v) => setHasTray(v !== false))
.catch(() => setHasTray(true))
.finally(() => setOpen(true));
});
const choose = useCallback( const choose = useCallback(
(value: 'tray' | 'quit') => { (value: 'tray' | 'quit') => {
@@ -65,7 +51,7 @@ export function CloseBehaviorPrompt() {
<OverlayCenter> <OverlayCenter>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: hasTray ? '#close-behavior-keep' : '#close-behavior-quit', initialFocus: '#close-behavior-keep',
// Dismissing without choosing keeps the window open; the next close // Dismissing without choosing keeps the window open; the next close
// asks again. // asks again.
onDeactivate: () => setOpen(false), onDeactivate: () => setOpen(false),
@@ -94,9 +80,8 @@ export function CloseBehaviorPrompt() {
</Header> </Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" id="close-behavior-body"> <Text priority="400" id="close-behavior-body">
{hasTray Lotus Chat can keep running in the system tray when you close this window, so
? 'Lotus Chat can keep running in the system tray when you close this window, so messages, notifications and calls still reach you. Open it again from the tray icon.' messages, notifications and calls still reach you. Open it again from the tray icon.
: "Your desktop doesn't show tray icons, so if Lotus Chat keeps running after you close this window, the only way back is to launch it again."}
</Text> </Text>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}> <Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox <Checkbox
@@ -110,43 +95,12 @@ export function CloseBehaviorPrompt() {
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text> <Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
</Box> </Box>
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
{hasTray ? ( <Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
<> <Text size="B400">Keep running in the tray</Text>
<Button </Button>
id="close-behavior-keep" <Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
variant="Primary" <Text size="B400">Quit when I close the window</Text>
onClick={() => choose('tray')} </Button>
>
<Text size="B400">Keep running in the tray</Text>
</Button>
<Button
id="close-behavior-quit"
variant="Secondary"
fill="Soft"
onClick={() => choose('quit')}
>
<Text size="B400">Quit when I close the window</Text>
</Button>
</>
) : (
<>
<Button
id="close-behavior-quit"
variant="Primary"
onClick={() => choose('quit')}
>
<Text size="B400">Quit when I close the window</Text>
</Button>
<Button
id="close-behavior-keep"
variant="Secondary"
fill="Soft"
onClick={() => choose('tray')}
>
<Text size="B400">Keep running in the background</Text>
</Button>
</>
)}
</Box> </Box>
<Text size="T200" priority="300"> <Text size="T200" priority="300">
You can change this in Settings → General. You can change this in Settings → General.
+14 -54
View File
@@ -1,4 +1,4 @@
import React, { forwardRef, useCallback, useEffect, useState } from 'react'; import React, { forwardRef, useCallback } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
@@ -9,8 +9,6 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle'; import { useModalStyle } from '../hooks/useModalStyle';
import { useCrossSigningActive } from '../hooks/useCrossSigning'; import { useCrossSigningActive } from '../hooks/useCrossSigning';
import { InfoCard } from './info-card'; import { InfoCard } from './info-card';
import { ScheduledMessage } from '../state/scheduledMessages';
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
import { import {
useDeviceVerificationStatus, useDeviceVerificationStatus,
VerificationStatus, VerificationStatus,
@@ -38,36 +36,17 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom); const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
// [Gitea #322] Scheduled messages live on the server and still send after const [logoutState, logout] = useAsyncCallback<void, Error, []>(
// logout, where no client would show them. Say so, and offer to cancel. useCallback(async () => {
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]); // [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
useEffect(() => { // membership (expires: 4 h) stays behind as a ghost participant and
let alive = true; // everyone else sees you "in call" until it times out.
fetchScheduledMessages(mx) if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
.then((list) => alive && setScheduled(list)) await hangupCallAndWait(mx, callEmbed);
.catch(() => undefined); setCallEmbed(undefined);
return () => { }
alive = false; await logoutClient(mx);
}; }, [mx, callEmbed, setCallEmbed]),
}, [mx]);
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
useCallback(
async (cancelScheduled?: boolean) => {
if (cancelScheduled) {
await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId)));
}
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
// membership (expires: 4 h) stays behind as a ghost participant and
// everyone else sees you "in call" until it times out.
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
await hangupCallAndWait(mx, callEmbed);
setCallEmbed(undefined);
}
await logoutClient(mx);
},
[mx, callEmbed, setCallEmbed, scheduled],
),
); );
const ongoingLogout = logoutState.status === AsyncStatus.Loading; const ongoingLogout = logoutState.status === AsyncStatus.Loading;
@@ -114,13 +93,6 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages." description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
/> />
))} ))}
{scheduled.length > 0 && (
<InfoCard
variant="Warning"
title={`${scheduled.length} scheduled message${scheduled.length === 1 ? '' : 's'}`}
description="Scheduled messages still send after you log out. Cancel them now if you don't want them sent."
/>
)}
<Text priority="400">You’re about to log out. Are you sure?</Text> <Text priority="400">You’re about to log out. Are you sure?</Text>
{logoutState.status === AsyncStatus.Error && ( {logoutState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300"> <Text style={{ color: color.Critical.Main }} size="T300">
@@ -130,24 +102,12 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Button <Button
variant="Critical" variant="Critical"
onClick={() => logout()} onClick={logout}
disabled={ongoingLogout} disabled={ongoingLogout}
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />} before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
> >
<Text size="B400"> <Text size="B400">Logout</Text>
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
</Text>
</Button> </Button>
{scheduled.length > 0 && (
<Button
variant="Critical"
fill="Soft"
onClick={() => logout(true)}
disabled={ongoingLogout}
>
<Text size="B400">Cancel scheduled messages and logout</Text>
</Button>
)}
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}> <Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
<Text size="B400">Cancel</Text> <Text size="B400">Cancel</Text>
</Button> </Button>
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji'; import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels'; import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey } from '../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../hooks/useRecentEmoji';
@@ -612,13 +612,7 @@ export function EmojiBoard({
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt), !editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt), !editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
// [Gitea #304] The board opens with focus in its search field, where escapeDeactivates: stopPropagation,
// the shared stopPropagation helper ignores Escape. Always close here,
// without letting the key also close the room/modal underneath.
escapeDeactivates: (evt: KeyboardEvent) => {
evt.stopPropagation();
return true;
},
}} }}
> >
<EmojiBoardLayout <EmojiBoardLayout
@@ -51,10 +51,9 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css'; import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { useModalStyle } from '../../hooks/useModalStyle'; import { useModalStyle } from '../../hooks/useModalStyle';
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 1000,
@@ -76,14 +75,10 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const [showQr, setShowQr] = useState(false); const [showQr, setShowQr] = useState(false);
// [Gitea #285] A link into this deployment; matrix.to can't open Lotus Chat.
const lotusBase = useLotusShareBase();
const inviteUrl = (() => { const inviteUrl = (() => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
return room.isSpaceRoom() return getMatrixToRoom(roomIdOrAlias, viaServers);
? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers)
: getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers);
})(); })();
const handleCopyLink = () => { const handleCopyLink = () => {
@@ -1,7 +1,7 @@
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds'; import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk'; import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName, sendRoomEvent } from '../../../utils/room'; import { getMemberName, sendRoomEvent } from '../../../utils/room';
import { import {
@@ -99,21 +99,7 @@ export function PollContent({
}) { }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const roomId = room.roomId; const roomId = room.roomId;
// [Gitea #284] A poll you just created starts with its local-echo id const eventId = mEvent.getId();
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
// doesn't re-render us. Track the id so votes and the tally use the real one.
const [eventId, setEventId] = useState(() => mEvent.getId());
useEffect(() => {
const sync = () => setEventId(mEvent.getId());
sync();
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.on(MatrixEventEvent.Status, sync);
return () => {
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.off(MatrixEventEvent.Status, sync);
};
}, [mEvent]);
const isLocalEcho = !!eventId?.startsWith('~');
const senderId = mEvent.getSender(); const senderId = mEvent.getSender();
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]); const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
@@ -206,9 +192,8 @@ export function PollContent({
const isMultiple = maxSelections > 1; const isMultiple = maxSelections > 1;
const showResults = resultsVisible(isUndisclosed, isEnded); const showResults = resultsVisible(isUndisclosed, isEnded);
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded; const canVote = !!roomId && !!eventId && !isEnded;
const canEnd = const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
const myVotes = pending ?? tally.myVotes; const myVotes = pending ?? tally.myVotes;
const { counts, total, voters } = tally; const { counts, total, voters } = tally;
@@ -234,34 +219,25 @@ export function PollContent({
setPending(next); setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader // Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces). // accepts both namespaces).
// The SDK can throw synchronously (e.g. a relation to a pending event). sendRoomEvent(mx, roomId, null, 'm.poll.response', {
try { 'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
sendRoomEvent(mx, roomId, null, 'm.poll.response', { 'm.selections': Array.from(next),
'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, }).catch(() => setPending(null));
'm.selections': Array.from(next),
}).catch(() => setPending(null));
} catch {
setPending(null);
}
}; };
const handleEndPoll = () => { const handleEndPoll = () => {
if (!roomId || !eventId || ending) return; if (!roomId || !eventId || ending) return;
setEnding(true); setEnding(true);
try { sendRoomEvent(mx, roomId, null, 'm.poll.end', {
sendRoomEvent(mx, roomId, null, 'm.poll.end', { 'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, 'm.poll.end': {},
'm.poll.end': {}, 'm.text': 'The poll has ended.',
'm.text': 'The poll has ended.', })
.then(() => {
setConfirmEnd(false);
setEnding(false);
}) })
.then(() => { .catch(() => setEnding(false));
setConfirmEnd(false);
setEnding(false);
})
.catch(() => setEnding(false));
} catch {
setEnding(false);
}
}; };
// Radiogroup keyboard model (single-choice only): arrows move focus + selection. // Radiogroup keyboard model (single-choice only): arrows move focus + selection.
+1 -15
View File
@@ -6,13 +6,12 @@ import {
useCallJoined, useCallJoined,
useCallEmbedPlacementSync, useCallEmbedPlacementSync,
useCallLoadError, useCallLoadError,
useCallStart,
} from '../../hooks/useCallEmbed'; } from '../../hooks/useCallEmbed';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls'; import { PrescreenControls } from './PrescreenControls';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -161,16 +160,6 @@ function CallLoadErrorMessage() {
// prescreen, where they can choose to join again. // prescreen, where they can choose to join again.
const dismiss = () => setCallEmbed(undefined); const dismiss = () => setCallEmbed(undefined);
// [Gitea #301] Try again: start a fresh embed for the same room with the
// same mic/camera/sound choices (starting one disposes the hung one).
const callEmbed = useCallEmbed();
const startCall = useCallStart(useIsDirectRoom());
const retry = () => {
if (!callEmbed) return;
const { room, control } = callEmbed;
startCall(room, { microphone: control.microphone, video: control.video, sound: control.sound });
};
return ( return (
<Box <Box
className={css.CallViewContent} className={css.CallViewContent}
@@ -184,9 +173,6 @@ function CallLoadErrorMessage() {
The call failed to load. Check your connection and try again. The call failed to load. Check your connection and try again.
</Text> </Text>
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
<Button variant="Primary" size="300" radii="400" onClick={retry} disabled={!callEmbed}>
<Text size="B400">Try again</Text>
</Button>
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}> <Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Back</Text> <Text size="B400">Back</Text>
</Button> </Button>
@@ -1,7 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
foldEdits,
filterGroupsByDateRange, filterGroupsByDateRange,
filterGroupsByMsgType, filterGroupsByMsgType,
filterGroupsByPinned, filterGroupsByPinned,
@@ -110,47 +109,3 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
['$2'], ['$2'],
); );
}); });
const sr = (eventId: string, ts: number, content: Record<string, unknown>) =>
({
rank: 1,
result: {
event_id: eventId,
room_id: '!r:x',
origin_server_ts: ts,
type: 'm.room.message',
content,
},
}) as unknown as Parameters<typeof foldEdits>[0][number];
test('foldEdits: one row per message, showing the latest edit (#288)', () => {
const edit = (id: string, ts: number, body: string) =>
sr(id, ts, {
body: `* ${body}`,
'm.new_content': { body },
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
});
const out = foldEdits([
edit('$e2', 30, 'Tuesday 11am room B'),
sr('$orig', 10, { body: 'Monday 10am room A' }),
edit('$e1', 20, 'Tuesday 10am room A'),
sr('$other', 5, { body: 'meeting notes' }),
]);
assert.deepEqual(
out.map((r) => r.result.event_id),
['$orig', '$other'],
);
assert.deepEqual(out[0].result.content['m.new_content'], { body: 'Tuesday 11am room B' });
assert.equal(out[0].result.content.body, 'Monday 10am room A');
});
test('foldEdits: an edit whose original did not match stands in for it', () => {
const out = foldEdits([
sr('$e1', 20, {
'm.new_content': { body: 'x' },
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
}),
]);
assert.equal(out.length, 1);
assert.equal(out[0].result.event_id, '$orig');
});
@@ -96,58 +96,10 @@ export const filterGroupsByDateRange = (
.filter((group) => group.items.length > 0); .filter((group) => group.items.length > 0);
}; };
/**
* [Gitea #288] Server search also returns `m.replace` events, so an edited
* message showed once per version. Fold each edit into its message: one row,
* at the first position any version ranked, showing the latest text and
* pointing at the original event (so "jump" lands on the real message).
*/
export const foldEdits = (results: ISearchResult[]): ISearchResult[] => {
const targetOf = (r: ISearchResult): string => {
const rel = r.result.content?.['m.relates_to'];
return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string'
? rel.event_id
: r.result.event_id;
};
const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id;
const order: string[] = [];
const originals = new Map<string, ISearchResult>();
const latestEdit = new Map<string, ISearchResult>();
results.forEach((r) => {
const target = targetOf(r);
if (!originals.has(target) && !latestEdit.has(target)) order.push(target);
if (!isEdit(r)) {
originals.set(target, r);
return;
}
const prev = latestEdit.get(target);
if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts)
latestEdit.set(target, r);
});
return order.map((target) => {
const original = originals.get(target);
const edit = latestEdit.get(target);
const newContent = edit?.result.content?.['m.new_content'];
if (original && !newContent) return original;
if (original) {
return {
...original,
result: {
...original.result,
content: { ...original.result.content, 'm.new_content': newContent },
},
};
}
// Only an edit matched: show it as its message.
const only = edit as ISearchResult;
return { ...only, result: { ...only.result, event_id: target } };
});
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
foldEdits(results).forEach((item) => { results.forEach((item) => {
const roomId = item.result.room_id; const roomId = item.result.room_id;
const resultItem: ResultItem = { const resultItem: ResultItem = {
rank: item.rank, rank: item.rank,
+1 -8
View File
@@ -90,7 +90,6 @@ import {
isMembershipChanged, isMembershipChanged,
reactionOrEditEvent, reactionOrEditEvent,
sendRoomEvent, sendRoomEvent,
isFromFilteredTimelineSet,
} from '../../utils/room'; } from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff'; import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics'; import { tick } from '../../utils/haptics';
@@ -139,7 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom'; import { useIsDirectRoom } from '../../hooks/useRoom';
import { declinedRing, isRingableRoom } from '../../utils/callRing'; import { isRingableRoom } from '../../utils/callRing';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
@@ -411,9 +410,6 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
data, data,
) => { ) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
// [Gitea #259] The media gallery / activity log register filtered timeline
// sets whose events the room re-emits as live: don't count them twice.
if (isFromFilteredTimelineSet(data)) return;
onArrive(mEvent); onArrive(mEvent);
}; };
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => { const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
@@ -2041,9 +2037,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Same rule as ringing: no "Missed call" in voice rooms or space // Same rule as ringing: no "Missed call" in voice rooms or space
// channels, where starting a call is just joining the channel. // channels, where starting a call is just joining the channel.
if (!isRingableRoom(room, direct)) return null; if (!isRingableRoom(room, direct)) return null;
// [Gitea #313] A call we declined isn't a missed call.
const ringRef = mEvent.getRelation()?.event_id;
if (declinedRing(room, [mEventId, ringRef], mx.getSafeUserId())) return null;
const content = mEvent.getContent(); const content = mEvent.getContent();
const mention = const mention =
content['m.mentions']?.room || content['m.mentions']?.room ||
@@ -4,12 +4,7 @@ import { IContent } from 'matrix-js-sdk';
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds'; import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages'; import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
import { import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
cancelScheduledMessage,
fetchScheduledMessages,
groupScheduledByRoom,
sendScheduledMessageNow,
} from '../../utils/scheduledMessages';
import { ScheduleMessageModal } from './ScheduleMessageModal'; import { ScheduleMessageModal } from './ScheduleMessageModal';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { formatFriendlyDateTime } from '../../utils/datetimeInput'; import { formatFriendlyDateTime } from '../../utils/datetimeInput';
@@ -31,26 +26,6 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]); const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
// [Gitea #322] The server is the source of truth: messages scheduled on
// another device (or before a logout) show up here and can be cancelled.
// localStorage is only a cache for the first paint; on error we keep it.
useEffect(() => {
let alive = true;
const load = () => {
fetchScheduledMessages(mx)
.then((list) => {
if (alive) setScheduledMessages(groupScheduledByRoom(list));
})
.catch(() => undefined);
};
load();
window.addEventListener('focus', load);
return () => {
alive = false;
window.removeEventListener('focus', load);
};
}, [mx, roomId, setScheduledMessages]);
// Remove scheduled messages whose time has passed // Remove scheduled messages whose time has passed
useEffect(() => { useEffect(() => {
if (messages.length === 0) return undefined; if (messages.length === 0) return undefined;
@@ -14,7 +14,6 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils'; import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport'; import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt'; import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
import { isFromFilteredTimelineSet } from '../../../utils/room';
type PendingAsk = { type PendingAsk = {
requests: WidgetPermissionRequest[]; requests: WidgetPermissionRequest[];
@@ -112,7 +111,6 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
}; };
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => { const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return; if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed); if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev); else feed(ev);
}; };
@@ -9,7 +9,6 @@ import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom } from '../../state/serverStatus'; import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus'; import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
const DISMISS_KEY = 'lotus-status-banner-dismissed'; const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50; const DISMISS_MAX = 50;
@@ -164,12 +163,7 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
}); });
}, []), }, []),
); );
// [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall const syncLost = lostAt !== null;
// watchdog flags it sooner, and Kuma is asked at once.
const stalled = useHomeserverStall(mx);
const setStalled = useSetAtom(homeserverStalledAtom);
useEffect(() => setStalled(stalled), [setStalled, stalled]);
const syncLost = lostAt !== null || stalled;
const status = useServerStatus(page, syncLost); const status = useServerStatus(page, syncLost);
const setAtom = useSetAtom(serverStatusAtom); const setAtom = useSetAtom(serverStatusAtom);
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]); useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
+1 -25
View File
@@ -17,8 +17,6 @@ import {
CALL_SESSION_HEARTBEAT_MS, CALL_SESSION_HEARTBEAT_MS,
clearCallSession, clearCallSession,
decideRejoin, decideRejoin,
othersInCall,
rejoinToastBody,
readCallSession, readCallSession,
writeCallSession, writeCallSession,
} from '../utils/callRejoin'; } from '../utils/callRejoin';
@@ -114,27 +112,6 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
}; };
dismissToast(TOAST_ID); dismissToast(TOAST_ID);
clearCallSession(); clearCallSession();
// [Gitea #302] A DM call the other person has since left is over;
// starting it again would ring them, so just say so.
const dm = l.directs.has(room.roomId);
if (
dm &&
!othersInCall(
session.memberships.map((m) => m.userId ?? ''),
mx.getUserId() ?? '',
)
) {
// Own id: clicking a toast removes it by id after its onClick runs.
setToast({
id: `${TOAST_ID}-ended`,
iconSrc: Icons.Phone,
displayName: 'Call ended',
body: `${room.name ?? 'They'} already left the call.`,
roomName: room.name ?? '',
roomId: room.roomId,
});
return;
}
try { try {
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref); (l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
} catch { } catch {
@@ -156,7 +133,6 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
.filter((m) => m.userId === mx.getUserId()) .filter((m) => m.userId === mx.getUserId())
.map((m) => m.deviceId), .map((m) => m.deviceId),
memberCount: memberships.length, memberCount: memberships.length,
dm: latest.current.directs.has(room.roomId),
}); });
// The RTC session fills in a moment after sync; keep waiting while it is // The RTC session fills in a moment after sync; keep waiting while it is
// empty unless this is the final (timed-out) look. // empty unless this is the final (timed-out) look.
@@ -176,7 +152,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
id: TOAST_ID, id: TOAST_ID,
iconSrc: Icons.Phone, iconSrc: Icons.Phone,
displayName: 'Rejoin voice?', displayName: 'Rejoin voice?',
body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)), body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
roomName: room.name ?? '', roomName: room.name ?? '',
roomId: room.roomId, roomId: room.roomId,
sticky: true, sticky: true,
-16
View File
@@ -1,16 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SyncState } from 'matrix-js-sdk';
import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall';
test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => {
const now = 1_000_000;
assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true);
assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll');
assert.equal(
syncOverdue(now - 120_000, now, SyncState.Reconnecting),
false,
'SyncStatus already shows that',
);
assert.equal(syncOverdue(now - 120_000, now, null), false);
});
-57
View File
@@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
import { atom } from 'jotai';
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
/**
* [Gitea #317] A hung homeserver (accepts connections, never answers) went
* unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after
* pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays
* SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a
* /sync that's overdue and confirms with a short probe of /versions.
*/
export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s
const CHECK_EVERY_MS = 10_000;
const PROBE_TIMEOUT_MS = 5_000;
export const homeserverStalledAtom = atom(false);
/** Pure: should we probe, given the last sync response time? */
export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean =>
state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS;
export function useHomeserverStall(mx: MatrixClient): boolean {
const [stalled, setStalled] = useState(false);
useEffect(() => {
let lastSyncAt = Date.now();
let probing = false;
const onSync = () => {
lastSyncAt = Date.now();
setStalled(false);
};
const check = async () => {
if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return;
probing = true;
let answered = false;
try {
const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, {
cache: 'no-store',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
answered = res.ok;
} catch {
answered = false;
}
probing = false;
// Still overdue and the server didn't answer a trivial request: stalled.
if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true);
if (answered) setStalled(false);
};
mx.on(ClientEvent.Sync, onSync);
const id = setInterval(check, CHECK_EVERY_MS);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
clearInterval(id);
};
}, [mx]);
return stalled;
}
@@ -49,7 +49,6 @@ import {
getNotificationType, getNotificationType,
getUnreadInfo, getUnreadInfo,
isNotificationEvent, isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType } from '../../../types/matrix/room'; import { NotificationType } from '../../../types/matrix/room';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
@@ -685,7 +684,6 @@ function MessageNotifications() {
data, data,
) => { ) => {
if (mx.getSyncState() !== 'SYNCING') return; if (mx.getSyncState() !== 'SYNCING') return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
if ( if (
!room || !room ||
@@ -1128,7 +1126,6 @@ function ClockSkewFeature() {
data, data,
) => { ) => {
if (!data.liveEvent) return; if (!data.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// Only events our homeserver stamped: a federated event's // Only events our homeserver stamped: a federated event's
// origin_server_ts is the other server's clock. // origin_server_ts is the other server's clock.
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':'); const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
+7 -31
View File
@@ -6,7 +6,6 @@ import { useSyncState } from '../../hooks/useSyncState';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { serverStatusAtom } from '../../state/serverStatus'; import { serverStatusAtom } from '../../state/serverStatus';
import { serverConfirmedUpSince } from '../../utils/kumaStatus'; import { serverConfirmedUpSince } from '../../utils/kumaStatus';
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
type StateData = { type StateData = {
current: SyncState | null; current: SyncState | null;
@@ -18,7 +17,6 @@ type SyncStatusProps = {
}; };
export function SyncStatus({ mx }: SyncStatusProps) { export function SyncStatus({ mx }: SyncStatusProps) {
const serverStatus = useAtomValue(serverStatusAtom); const serverStatus = useAtomValue(serverStatusAtom);
const stalled = useAtomValue(homeserverStalledAtom);
const [stateData, setStateData] = useState<StateData>({ const [stateData, setStateData] = useState<StateData>({
current: null, current: null,
previous: undefined, previous: undefined,
@@ -36,35 +34,6 @@ export function SyncStatus({ mx }: SyncStatusProps) {
}, []), }, []),
); );
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
// [Gitea #317] Sync still says SYNCING, but the server has stopped answering.
if (stalled && stateData.current === SyncState.Syncing && !serverDown) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
role="status"
aria-live="polite"
>
<Text size="L400">
Lotus Chat&apos;s server isn&apos;t responding. Messages will send when it&apos;s back.
Still trying…
</Text>
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
if ( if (
(stateData.current === SyncState.Prepared || (stateData.current === SyncState.Prepared ||
stateData.current === SyncState.Syncing || stateData.current === SyncState.Syncing ||
@@ -88,6 +57,13 @@ export function SyncStatus({ mx }: SyncStatusProps) {
); );
} }
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
if (stateData.current === SyncState.Reconnecting && !serverDown) { if (stateData.current === SyncState.Reconnecting && !serverDown) {
return ( return (
<Box direction="Column" shrink="No"> <Box direction="Column" shrink="No">
+134 -185
View File
@@ -255,8 +255,6 @@ function HomeCustomSection({
return (localName ?? matrixName).toLowerCase().includes(query); return (localName ?? matrixName).toLowerCase().includes(query);
}); });
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]); }, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
// [Gitea #289] While filtering, a section with no matches is hidden.
if (filterQuery.trim() && items.length === 0) return null;
// Sections are user-curated and small, so they render plainly — no // Sections are user-curated and small, so they render plainly — no
// virtualizer per section (a late-mounted one never measured its scroller). // virtualizer per section (a late-mounted one never measured its scroller).
return ( return (
@@ -445,26 +443,6 @@ export function Home() {
}); });
}, [mx, sortedRooms, filterQuery]); }, [mx, sortedRooms, filterQuery]);
// [Gitea #289] While filtering, hide empty sections; with no match at all,
// say so instead of leaving bare headers.
const filtering = filterQuery.trim().length > 0;
const customSectionMatch = useMemo(() => {
if (!filtering) return true;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return customSections.some((section) =>
section.rooms.some((rId) =>
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
),
);
}, [mx, customSections, filtering, filterQuery]);
const noFilterMatch =
filtering &&
!customSectionMatch &&
filteredFavoriteRooms.length === 0 &&
filteredRooms.length === 0 &&
filteredLowPriorityRooms.length === 0;
const favVirtualizer = useVirtualizer({ const favVirtualizer = useVirtualizer({
count: filteredFavoriteRooms.length, count: filteredFavoriteRooms.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
@@ -602,7 +580,7 @@ export function Home() {
/> />
</Box> </Box>
</NavCategory> </NavCategory>
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && ( {favoriteRooms.length > 0 && (
<NavCategory> <NavCategory>
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
@@ -651,171 +629,142 @@ export function Home() {
notificationPreferences={notificationPreferences} notificationPreferences={notificationPreferences}
/> />
))} ))}
{noFilterMatch && ( <NavCategory>
<NavCategory> <NavCategoryHeader>
<Box <RoomNavCategoryButton
direction="Column" closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
alignItems="Center" data-category-id={DEFAULT_CATEGORY_ID}
gap="200" onClick={handleCategoryClick}
style={{ padding: config.space.S400 }}
> >
<Text size="T300" priority="300" align="Center"> Rooms
No rooms match &ldquo;{filterQuery.trim()}&rdquo; </RoomNavCategoryButton>
</Text> {!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<Button <>
size="300" <IconButton
variant="Secondary" aria-expanded={!!sortMenuAnchor}
fill="Soft" aria-haspopup="menu"
radii="300" variant="Background"
onClick={() => setFilterQuery('')} fill="None"
> size="300"
<Text size="B300">Clear filter</Text> radii="300"
</Button> onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
</Box> const cords = evt.currentTarget.getBoundingClientRect();
</NavCategory> setSortMenuAnchor((current) => (current ? undefined : cords));
)} }}
{(!filtering || filteredRooms.length > 0) && ( aria-label="Sort rooms"
<NavCategory> >
<NavCategoryHeader> <Icon src={Icons.Sort} size="100" />
<RoomNavCategoryButton </IconButton>
closed={closedCategories.has(DEFAULT_CATEGORY_ID)} <PopOut
data-category-id={DEFAULT_CATEGORY_ID} anchor={sortMenuAnchor}
onClick={handleCategoryClick} position="Bottom"
> align="End"
Rooms offset={6}
</RoomNavCategoryButton> content={
{!closedCategories.has(DEFAULT_CATEGORY_ID) && ( <FocusTrap
<> focusTrapOptions={{
<IconButton initialFocus: false,
aria-expanded={!!sortMenuAnchor} onDeactivate: () => setSortMenuAnchor(undefined),
aria-haspopup="menu" clickOutsideDeactivates: true,
variant="Background" isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
fill="None" isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
size="300" escapeDeactivates: stopPropagation,
radii="300" }}
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => { >
const cords = evt.currentTarget.getBoundingClientRect(); <Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
setSortMenuAnchor((current) => (current ? undefined : cords)); <Box
}} direction="Column"
aria-label="Sort rooms" gap="100"
> style={{ padding: config.space.S100 }}
<Icon src={Icons.Sort} size="100" /> >
</IconButton> <MenuItem
<PopOut onClick={() => {
anchor={sortMenuAnchor} setHomeRoomSort('recent');
position="Bottom" setSortMenuAnchor(undefined);
align="End" }}
offset={6} size="300"
content={ after={
<FocusTrap homeRoomSort === 'recent' ? (
focusTrapOptions={{ <Icon size="100" src={Icons.Check} />
initialFocus: false, ) : undefined
onDeactivate: () => setSortMenuAnchor(undefined), }
clickOutsideDeactivates: true, radii="300"
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
> >
<MenuItem <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
onClick={() => { Recent Activity
setHomeRoomSort('recent'); </Text>
setSortMenuAnchor(undefined); </MenuItem>
}} <MenuItem
size="300" onClick={() => {
after={ setHomeRoomSort('alpha');
homeRoomSort === 'recent' ? ( setSortMenuAnchor(undefined);
<Icon size="100" src={Icons.Check} /> }}
) : undefined size="300"
} after={
radii="300" homeRoomSort === 'alpha' ? (
> <Icon size="100" src={Icons.Check} />
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> ) : undefined
Recent Activity }
</Text> radii="300"
</MenuItem> >
<MenuItem <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
onClick={() => { A → Z
setHomeRoomSort('alpha'); </Text>
setSortMenuAnchor(undefined); </MenuItem>
}} <MenuItem
size="300" onClick={() => {
after={ setHomeRoomSort('unread');
homeRoomSort === 'alpha' ? ( setSortMenuAnchor(undefined);
<Icon size="100" src={Icons.Check} /> }}
) : undefined size="300"
} after={
radii="300" homeRoomSort === 'unread' ? (
> <Icon size="100" src={Icons.Check} />
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> ) : undefined
A → Z }
</Text> radii="300"
</MenuItem> >
<MenuItem <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
onClick={() => { Unread First
setHomeRoomSort('unread'); </Text>
setSortMenuAnchor(undefined); </MenuItem>
}} </Box>
size="300" </Menu>
after={ </FocusTrap>
homeRoomSort === 'unread' ? ( }
<Icon size="100" src={Icons.Check} /> />
) : undefined </>
} )}
radii="300" </NavCategoryHeader>
> <div
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> style={{
Unread First position: 'relative',
</Text> height: virtualizer.getTotalSize(),
</MenuItem> }}
</Box> >
</Menu> {virtualizer.getVirtualItems().map((vItem) => {
</FocusTrap> const roomId = filteredRooms[vItem.index];
} const room = mx.getRoom(roomId);
/> if (!room) return null;
</> const selected = selectedRoomId === roomId;
)}
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return ( return (
<VirtualTile <VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
virtualItem={vItem} <RoomNavItem
key={roomId} room={room}
ref={virtualizer.measureElement} selected={selected}
> linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
<RoomNavItem notificationMode={getRoomNotificationMode(
room={room} notificationPreferences,
selected={selected} room.roomId,
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))} )}
notificationMode={getRoomNotificationMode( />
notificationPreferences, </VirtualTile>
room.roomId, );
)} })}
/> </div>
</VirtualTile> </NavCategory>
); {lowPriorityRooms.length > 0 && (
})}
</div>
</NavCategory>
)}
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
<NavCategory> <NavCategory>
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
-7
View File
@@ -91,13 +91,6 @@ export class CallControl extends EventEmitter implements CallControlState {
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
super(); super();
// [Gitea #316] More than 10 call features subscribe to state_update at once
// (call bar, controls, hotkeys, AFK, muted-talk, join/leave sounds,
// announcements, rejoin, policy toast, glance, desktop thumbbar/SMTC …).
// Hitting the default limit logged a MaxListenersExceededWarning, and the
// console then kept this CallControl — and through it the whole Element Call
// document — alive: ~11 MB leaked per call. These are not leaks, so lift it.
this.setMaxListeners(100);
this.state = state; this.state = state;
this.call = call; this.call = call;
+3
View File
@@ -218,6 +218,9 @@ export class CallEmbed {
// - transparent background so the room wallpaper shows through natively // - transparent background so the room wallpaper shows through natively
lotusCallState: 'true', lotusCallState: 'true',
lotusTransparent: 'true', lotusTransparent: 'true',
// [Gitea #293] the fork applies the theme with transparency anyway, and
// warned in every call that lotusTheme wasn't set
lotusTheme: 'true',
// [Gitea #43] The fork hides its own footer (we draw the call bar) and // [Gitea #43] The fork hides its own footer (we draw the call bar) and
// sets its root color-scheme from the theme, instead of us injecting CSS. // sets its root color-scheme from the theme, instead of us injecting CSS.
lotusHostControls: 'true', lotusHostControls: 'true',
+1 -14
View File
@@ -28,10 +28,7 @@ import {
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { getCallCapabilities } from './utils'; import { getCallCapabilities } from './utils';
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
import { dedupeInFlight } from '../../utils/inFlight';
/** A little longer than Element Call's own 2 s timeout for a restart. */
const RESTART_TIMEOUT_MS = 5000;
export class CallWidgetDriver extends WidgetDriver { export class CallWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>; private allowedCapabilities: Set<Capability>;
@@ -132,18 +129,8 @@ export class CallWidgetDriver extends WidgetDriver {
await this.mx._unstable_cancelScheduledDelayedEvent(delayId); await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
} }
// [Gitea #318] While the homeserver is stalled, Element Call times out each
// restart locally after 2 s and asks again, so requests for the same delay
// id piled up and hit the server together on recovery (404s, then 429s).
// Share one in-flight restart per id, and give it a timeout of its own.
private readonly restartOnce = dedupeInFlight<unknown>();
public async restartScheduledDelayedEvent(delayId: string): Promise<void> { public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
await this.restartOnce(delayId, () => await this.mx._unstable_restartScheduledDelayedEvent(delayId);
this.mx._unstable_restartScheduledDelayedEvent(delayId, {
localTimeoutMs: RESTART_TIMEOUT_MS,
}),
);
} }
public async sendScheduledDelayedEvent(delayId: string): Promise<void> { public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
-2
View File
@@ -24,7 +24,6 @@ import {
getUnreadInfo, getUnreadInfo,
getUnreadInfos, getUnreadInfos,
isNotificationEvent, isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room'; } from '../../utils/room';
import { roomToParentsAtom } from './roomToParents'; import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useStateEventCallback } from '../../hooks/useStateEventCallback';
@@ -237,7 +236,6 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
data: IRoomTimelineData, data: IRoomTimelineData,
) => { ) => {
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return; if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// Single-owner rule: thread replies drive the room badge via // Single-owner rule: thread replies drive the room badge via
// RoomEvent.UnreadNotifications below — ignore them here so the count is // RoomEvent.UnreadNotifications below — ignore them here so the count is
// never double-driven / mis-attributed to the main timeline. // never double-driven / mis-attributed to the main timeline.
+2 -2
View File
@@ -21,8 +21,8 @@ const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
/** /**
* Global atom: Map<roomId, ScheduledMessage[]> * Global atom: Map<roomId, ScheduledMessage[]>
* Stores all locally-tracked scheduled messages across rooms. * Stores all locally-tracked scheduled messages across rooms.
* Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts * MSC4140 has no list endpoint, so we track them ourselves.
* (Gitea #322); localStorage is a cache for the first paint. * Backed by localStorage so scheduled messages survive page refreshes.
*/ */
export const scheduledMessagesAtom = atom( export const scheduledMessagesAtom = atom(
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))), (get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
+1 -25
View File
@@ -1,12 +1,6 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
CALL_REJOIN_MAX_AGE_MS,
decideRejoin,
othersInCall,
rejoinToastBody,
RejoinContext,
} from './callRejoin';
const base: RejoinContext = { const base: RejoinContext = {
record: { record: {
@@ -60,21 +54,3 @@ describe('decideRejoin', () => {
}); });
}); });
}); });
it('decideRejoin: DM calls are never auto-rejoined, only offered (#302)', () => {
assert.deepEqual(decideRejoin({ ...base, mode: 'auto', dm: true }), {
action: 'ask',
roomId: '!r',
});
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
});
it('rejoinToastBody and othersInCall (#302)', () => {
assert.equal(
rejoinToastBody('Bob Builder', true),
'You were in a call with Bob Builder before the restart. Tap to rejoin.',
);
assert.match(rejoinToastBody('Voice Lounge', false), /in the call in Voice Lounge/);
assert.equal(othersInCall(['@me:x'], '@me:x'), false);
assert.equal(othersInCall(['@me:x', '@bob:x'], '@me:x'), true);
});
-15
View File
@@ -67,8 +67,6 @@ export type RejoinContext = {
ownMemberDevices: string[]; ownMemberDevices: string[];
/** Number of call memberships in the room, ours included. */ /** Number of call memberships in the room, ours included. */
memberCount: number; memberCount: number;
/** A DM call: rejoining one may ring the other person. */
dm?: boolean;
}; };
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string }; export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
@@ -85,18 +83,5 @@ export function decideRejoin(ctx: RejoinContext): RejoinDecision {
// Nobody there any more (our own stale membership doesn't count). // Nobody there any more (our own stale membership doesn't count).
const others = ctx.memberCount - ctx.ownMemberDevices.length; const others = ctx.memberCount - ctx.ownMemberDevices.length;
if (others <= 0) return { action: 'none' }; if (others <= 0) return { action: 'none' };
// [Gitea #302] Never rejoin a DM call on our own: if the other person has
// just left, starting it again would ring them. Ask instead.
if (ctx.dm && mode === 'auto') return { action: 'ask', roomId: record.roomId };
return { action: mode, roomId: record.roomId }; return { action: mode, roomId: record.roomId };
} }
/** Pure: whether anyone but us is still in the call (by call membership). */
export const othersInCall = (memberUserIds: string[], myUserId: string): boolean =>
memberUserIds.some((u) => u !== myUserId);
/** The rejoin toast's text; DMs name the person rather than "the call in <room>". */
export const rejoinToastBody = (roomName: string | undefined, dm: boolean): string =>
dm
? `You were in a call with ${roomName ?? 'them'} before the restart. Tap to rejoin.`
: `You were in the call in ${roomName ?? 'a room'} before the restart. Tap to rejoin.`;
-15
View File
@@ -67,18 +67,3 @@ test('declinedRing finds our own decline of that ring only (#312)', () => {
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline"); assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
assert.equal(declinedRing(room, '$ring3', '@me:x'), false); assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
}); });
test('declinedRing matches a decline of the notification or of the membership (#313)', () => {
const decline = (rel: string) => ({
getType: () => 'org.matrix.msc4310.rtc.decline',
getSender: () => '@me:x',
getRelation: () => ({ event_id: rel }),
});
const room = stateRoom([], [decline('$notification')]);
assert.equal(declinedRing(room, ['$membership', '$notification'], '@me:x'), true);
assert.equal(declinedRing(room, ['$membership'], '@me:x'), false);
assert.equal(
declinedRing(stateRoom([], [decline('$membership')]), ['$membership', undefined], '@me:x'),
true,
);
});
+2 -13
View File
@@ -40,18 +40,7 @@ export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
* declined it (our own RTCDecline relates to it) — e.g. replayed together with * declined it (our own RTCDecline relates to it) — e.g. replayed together with
* the ring in the first sync after a restart, before any dismiss listener. * the ring in the first sync after a restart, before any dismiss listener.
*/ */
export const declinedRing = ( export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
room: Room,
ringIds: string | Array<string | undefined>,
myUserId: string,
): boolean => {
// [Gitea #313] A decline references the notification event (MSC4310); older
// Lotus builds referenced the call membership instead. Match either.
const ids = new Set((Array.isArray(ringIds) ? ringIds : [ringIds]).filter(Boolean));
return declinedAny(room, ids, myUserId);
};
const declinedAny = (room: Room, ids: Set<string | undefined>, myUserId: string): boolean =>
room room
.getLiveTimeline() .getLiveTimeline()
.getEvents() .getEvents()
@@ -59,5 +48,5 @@ const declinedAny = (room: Room, ids: Set<string | undefined>, myUserId: string)
(ev) => (ev) =>
ev.getType() === 'org.matrix.msc4310.rtc.decline' && ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
ev.getSender() === myUserId && ev.getSender() === myUserId &&
ids.has(ev.getRelation()?.event_id), ev.getRelation()?.event_id === refEventId,
); );
-20
View File
@@ -1,20 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { IRoomTimelineData } from 'matrix-js-sdk';
import { isFromFilteredTimelineSet } from './room';
const data = (filter: unknown): IRoomTimelineData =>
({
liveEvent: true,
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
}) as unknown as IRoomTimelineData;
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
assert.equal(isFromFilteredTimelineSet(undefined), false);
assert.equal(
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
false,
);
});
-32
View File
@@ -1,32 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dedupeInFlight } from './inFlight';
test('dedupeInFlight: one request per key while pending, a new one after (#318)', async () => {
const once = dedupeInFlight<number>();
let calls = 0;
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
const run = async () => {
calls += 1;
await gate;
return calls;
};
const a = once('d1', run);
const b = once('d1', run);
const c = once('d2', run);
assert.equal(a, b);
release();
assert.deepEqual(await Promise.all([a, b, c]), [2, 2, 2]);
assert.equal(calls, 2);
await once('d1', run);
assert.equal(calls, 3, 'settled requests are not reused');
});
test('dedupeInFlight: a rejection clears the key', async () => {
const once = dedupeInFlight<void>();
await assert.rejects(once('k', () => Promise.reject(new Error('boom'))));
await once('k', () => Promise.resolve());
});
-14
View File
@@ -1,14 +0,0 @@
/**
* Share one in-flight promise per key: callers that ask again while a request
* for the same key is still pending get that request instead of a new one.
*/
export function dedupeInFlight<T>(): (key: string, run: () => Promise<T>) => Promise<T> {
const pending = new Map<string, Promise<T>>();
return (key, run) => {
const existing = pending.get(key);
if (existing) return existing;
const p = run().finally(() => pending.delete(key));
pending.set(key, p);
return p;
};
}
-12
View File
@@ -20,7 +20,6 @@ import {
Room, Room,
RoomMember, RoomMember,
TimelineEvents, TimelineEvents,
IRoomTimelineData,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events'; import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
@@ -848,14 +847,3 @@ export const guessPerfectParent = (
return perfectParent; return perfectParent;
}; };
/**
* [Gitea #259] Whether a RoomEvent.Timeline emit came from a FILTERED timeline
* set. The media gallery and activity log register filtered sets on the room
* (getOrCreateFilteredTimelineSet), and the room re-emits their events with
* liveEvent: true, so listeners that mean "a new event in the room" saw each
* matching event twice (the timeline window drifted, notifications doubled).
* The room's main and thread sets have no filter.
*/
export const isFromFilteredTimelineSet = (data?: IRoomTimelineData): boolean =>
!!data?.timeline?.getTimelineSet()?.getFilter();
-60
View File
@@ -6,8 +6,6 @@ import {
scheduleMessage, scheduleMessage,
cancelScheduledMessage, cancelScheduledMessage,
restartScheduledMessage, restartScheduledMessage,
parseDelayedEvents,
groupScheduledByRoom,
} from './scheduledMessages'; } from './scheduledMessages';
// Minimal MatrixClient stub: records every authedRequest call and returns a // Minimal MatrixClient stub: records every authedRequest call and returns a
@@ -121,61 +119,3 @@ test('cancel/restart url-encode the delay id', async () => {
await cancelScheduledMessage(mx, 'a/b c'); await cancelScheduledMessage(mx, 'a/b c');
assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c'); assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c');
}); });
test('parseDelayedEvents: Synapse shape, messages only, sendAt from running_since + delay (#322)', () => {
const res = {
delayed_events: [
{
delay_id: 'syd_a',
room_id: '!r:x',
type: 'm.room.message',
content: { msgtype: 'm.text', body: 'hi' },
delay: 60000,
running_since: 1000,
},
{
delay_id: 'syd_call',
room_id: '!r:x',
type: 'org.matrix.msc3401.call.member',
state_key: '_@a:x_D',
content: {},
delay: 8000,
running_since: 1000,
},
{ delay_id: 'syd_bad', room_id: '!r:x', type: 'm.room.message', content: {} },
null,
],
};
assert.deepEqual(parseDelayedEvents(res), [
{ delayId: 'syd_a', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' }, sendAt: 61000 },
]);
});
test('parseDelayedEvents: MSC `scheduled` shape and garbage', () => {
const item = {
delay_id: 'd',
room_id: '!r:x',
type: 'm.room.message',
content: { body: 'b' },
delay_ms: 5,
delayed_since_ts: 10,
};
assert.equal(parseDelayedEvents({ scheduled: [item] })[0].sendAt, 15);
assert.deepEqual(parseDelayedEvents(undefined), []);
assert.deepEqual(parseDelayedEvents({ delayed_events: 'nope' }), []);
});
test('groupScheduledByRoom: groups and sorts by sendAt', () => {
const m = (delayId: string, roomId: string, sendAt: number) => ({
delayId,
roomId,
content: {},
sendAt,
});
const g = groupScheduledByRoom([m('b', '!1', 20), m('a', '!1', 10), m('c', '!2', 5)]);
assert.deepEqual(
g.get('!1')?.map((x) => x.delayId),
['a', 'b'],
);
assert.equal(g.get('!2')?.length, 1);
});
-59
View File
@@ -1,5 +1,4 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk'; import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
import type { ScheduledMessage } from '../state/scheduledMessages';
/** /**
* Schedule a message via MSC4140 (delayed messages). * Schedule a message via MSC4140 (delayed messages).
@@ -77,61 +76,3 @@ export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string)
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' }, { prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
); );
} }
type DelayedEventItem = {
delay_id?: unknown;
room_id?: unknown;
type?: unknown;
state_key?: unknown;
content?: unknown;
delay?: unknown;
delay_ms?: unknown;
running_since?: unknown;
delayed_since_ts?: unknown;
};
/**
* [Gitea #322] Pure: the user's pending scheduled messages from an MSC4140
* `GET /delayed_events` response. Synapse answers `{ delayed_events: [...] }`;
* the current MSC shape is `{ scheduled: [...] }`. Call-membership delayed
* events share the endpoint, so only non-state `m.room.message`s are kept.
*/
export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
if (!res || typeof res !== 'object') return [];
const r = res as { delayed_events?: unknown; scheduled?: unknown };
const list = Array.isArray(r.delayed_events) ? r.delayed_events : r.scheduled;
if (!Array.isArray(list)) return [];
const out: ScheduledMessage[] = [];
list.forEach((raw: DelayedEventItem) => {
if (!raw || typeof raw !== 'object') return;
if (raw.type !== 'm.room.message' || raw.state_key !== undefined) return;
if (typeof raw.delay_id !== 'string' || typeof raw.room_id !== 'string') return;
if (!raw.content || typeof raw.content !== 'object') return;
const delay = Number(raw.delay ?? raw.delay_ms);
const since = Number(raw.running_since ?? raw.delayed_since_ts);
if (!Number.isFinite(delay) || !Number.isFinite(since)) return;
out.push({
delayId: raw.delay_id,
roomId: raw.room_id,
content: raw.content as IContent,
sendAt: since + delay,
});
});
return out;
}
/** The user's pending scheduled messages, as the server has them. */
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
});
return parseDelayedEvents(res);
}
/** Group by room, the shape of `scheduledMessagesAtom`. */
export function groupScheduledByRoom(msgs: ScheduledMessage[]): Map<string, ScheduledMessage[]> {
const map = new Map<string, ScheduledMessage[]>();
msgs.forEach((m) => map.set(m.roomId, [...(map.get(m.roomId) ?? []), m]));
map.forEach((list) => list.sort((a, b) => a.sendAt - b.sendAt));
return map;
}
+73 -64
View File
@@ -87,97 +87,106 @@ function copyPdfWorker() {
}; };
} }
// Lotus ML noise suppression: ship the RNNoise worklet/wasm + our getUserMedia // The denoise assets the fork loads from ./denoise/ at runtime, as
// shim alongside the vendored Element Call bundle, and inject the shim <script> // [source, path under denoise/] pairs; `dir: true` entries are whole trees.
// into EC's index.html so it runs before EC captures the mic. Runs after // Shared by the build copy and the dev server (Gitea #293).
// viteStaticCopy has populated dist/public/element-call (closeBundle order). function denoiseSources() {
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist');
const dfnVendor = path.resolve('build/denoise-vendor/deepfilternet');
return {
// All bundled denoise assets are REQUIRED: every entry backs a model the
// UI can select (RNNoise, Speex) or the optional noise gate.
sapphi: [
[path.join(sapphi, 'rnnoise/workletProcessor.js'), 'rnnoiseWorklet.js'],
[path.join(sapphi, 'rnnoise.wasm'), 'rnnoise.wasm'],
[path.join(sapphi, 'rnnoise_simd.wasm'), 'rnnoise_simd.wasm'],
[path.join(sapphi, 'speex/workletProcessor.js'), 'speexWorklet.js'],
[path.join(sapphi, 'speex.wasm'), 'speex.wasm'],
[path.join(sapphi, 'noiseGate/workletProcessor.js'), 'noiseGateWorklet.js'],
],
// DTLN (@workadventure/noise-suppression): a self-contained ES module that
// resolves its own worklet, LiteRT WASM runtime and TFLite models via
// `import.meta.url`, so its whole dist/ tree is served intact.
dtln: [path.resolve('node_modules/@workadventure/noise-suppression/dist'), 'workadventure'],
// DeepFilterNet 3: the ESM (worklet inlined) plus the vendored wasm and ONNX
// model (CDN-free / Tauri-CSP safe), in the package's expected v2/ layout.
dfn: [
[
path.resolve('node_modules/deepfilternet3-noise-filter/dist/index.esm.js'),
'deepfilternet/index.esm.js',
],
[path.join(dfnVendor, 'v2/pkg/df_bg.wasm'), 'deepfilternet/v2/pkg/df_bg.wasm'],
[
path.join(dfnVendor, 'v2/models/DeepFilterNet3_onnx.tar.gz'),
'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz',
],
],
};
}
// 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
// serves the same files at the same URLs, so `npm start` can test denoise.
function lotusDenoise() { function lotusDenoise() {
const DEV_PREFIX = '/public/element-call/denoise/';
return { return {
name: 'lotus-denoise', name: 'lotus-denoise',
configureServer(server) {
const { sapphi, dtln, dfn } = denoiseSources();
const files = new Map([...sapphi, ...dfn].map(([src, rel]) => [rel, src]));
const types = { '.js': 'text/javascript', '.wasm': 'application/wasm' };
server.middlewares.use((req, res, next) => {
const url = (req.url ?? '').split('?')[0];
if (!url.startsWith(DEV_PREFIX)) return next();
const rel = decodeURIComponent(url.slice(DEV_PREFIX.length));
let file = files.get(rel);
if (!file && rel.startsWith(`${dtln[1]}/`)) {
const candidate = path.resolve(dtln[0], rel.slice(dtln[1].length + 1));
if (candidate.startsWith(dtln[0] + path.sep)) file = candidate;
}
if (!file || !fs.existsSync(file)) return next();
res.setHeader('Content-Type', types[path.extname(file)] ?? 'application/octet-stream');
fs.createReadStream(file).pipe(res);
return undefined;
});
},
closeBundle() { closeBundle() {
const ecDir = path.resolve('dist/public/element-call'); const ecDir = path.resolve('dist/public/element-call');
if (!fs.existsSync(ecDir)) return; if (!fs.existsSync(ecDir)) return;
const denoiseDir = path.join(ecDir, 'denoise'); const denoiseDir = path.join(ecDir, 'denoise');
fs.mkdirSync(denoiseDir, { recursive: true }); fs.mkdirSync(denoiseDir, { recursive: true });
const { sapphi, dtln, dfn } = denoiseSources();
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist'); // A missing source means a partial/changed install would otherwise
// All bundled denoise assets are REQUIRED: every entry backs a model the // silently ship a broken ML feature (worklet 404 -> raw mic), so we fail
// UI can select (RNNoise, Speex) or the optional noise gate. A missing // the build instead.
// source means a partial/changed install would otherwise silently ship a const missing = sapphi.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
// broken ML feature (worklet 404 -> raw mic), so we fail the build instead.
const assets = [
[
path.join(sapphi, 'rnnoise/workletProcessor.js'),
path.join(denoiseDir, 'rnnoiseWorklet.js'),
],
[path.join(sapphi, 'rnnoise.wasm'), path.join(denoiseDir, 'rnnoise.wasm')],
[path.join(sapphi, 'rnnoise_simd.wasm'), path.join(denoiseDir, 'rnnoise_simd.wasm')],
[path.join(sapphi, 'speex/workletProcessor.js'), path.join(denoiseDir, 'speexWorklet.js')],
[path.join(sapphi, 'speex.wasm'), path.join(denoiseDir, 'speex.wasm')],
[
path.join(sapphi, 'noiseGate/workletProcessor.js'),
path.join(denoiseDir, 'noiseGateWorklet.js'),
],
];
const missing = assets.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (missing.length > 0) { if (missing.length > 0) {
throw new Error( throw new Error(
`[lotus-denoise] Required denoise asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${missing.join('\n ')}`, `[lotus-denoise] Required denoise asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${missing.join('\n ')}`,
); );
} }
assets.forEach(([s, d]) => fs.copyFileSync(s, d)); sapphi.forEach(([s, rel]) => fs.copyFileSync(s, path.join(denoiseDir, rel)));
// DTLN (@workadventure/noise-suppression): unlike the flat sapphi const dtlnEntry = path.join(dtln[0], 'audio-worklet.js');
// worklets, this package is a self-contained ES module that resolves its
// own AudioWorklet processor, LiteRT WASM runtime and TFLite models via
// `import.meta.url`. So we copy its whole dist/ tree intact to
// denoise/workadventure/ (preserving assets/ + vendor/) and let the shim
// dynamic-import denoise/workadventure/audio-worklet.js at runtime. The
// entry module is required — fail the build if the install is broken.
const dtlnSrc = path.resolve('node_modules/@workadventure/noise-suppression/dist');
const dtlnEntry = path.join(dtlnSrc, 'audio-worklet.js');
if (!fs.existsSync(dtlnEntry)) { if (!fs.existsSync(dtlnEntry)) {
throw new Error( throw new Error(
`[lotus-denoise] DTLN entry missing (${dtlnEntry}) — build aborted. ` + `[lotus-denoise] DTLN entry missing (${dtlnEntry}) — build aborted. ` +
'Run `npm ci` to install @workadventure/noise-suppression.', 'Run `npm ci` to install @workadventure/noise-suppression.',
); );
} }
fs.cpSync(dtlnSrc, path.join(denoiseDir, 'workadventure'), { recursive: true }); fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), { recursive: true });
// DeepFilterNet 3 (deepfilternet3-noise-filter): the npm package ships only const dfnMissing = dfn.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
// its ESM (index.esm.js) with the AudioWorklet processor + wasm-bindgen glue
// INLINED as a string (loaded via a Blob URL, no CDN for the worklet). The
// only runtime CDN fetches are its single-threaded `df_bg.wasm` and the
// ONNX `DeepFilterNet3_onnx.tar.gz` model — which we VENDOR locally (under
// build/denoise-vendor/deepfilternet/) and serve, overriding the package's
// cdnUrl to our self-hosted base. This keeps the feature CDN-free / Tauri-CSP
// safe. We copy the ESM (the shim dynamic-imports it, mirroring DTLN) plus
// the vendored assets, preserving the package's expected v2/... layout. All
// are required — a missing entry means a broken install, so fail the build.
const dfnEsm = path.resolve('node_modules/deepfilternet3-noise-filter/dist/index.esm.js');
const dfnVendor = path.resolve('build/denoise-vendor/deepfilternet');
const dfnAssets = [
[dfnEsm, path.join(denoiseDir, 'deepfilternet/index.esm.js')],
[
path.join(dfnVendor, 'v2/pkg/df_bg.wasm'),
path.join(denoiseDir, 'deepfilternet/v2/pkg/df_bg.wasm'),
],
[
path.join(dfnVendor, 'v2/models/DeepFilterNet3_onnx.tar.gz'),
path.join(denoiseDir, 'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz'),
],
];
const dfnMissing = dfnAssets.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (dfnMissing.length > 0) { if (dfnMissing.length > 0) {
throw new Error( throw new Error(
'[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid ' + `[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${dfnMissing.join('\n ')}\n(Run \`npm ci\`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)`,
'shipping a broken ML feature:\n ' +
dfnMissing.join('\n ') +
'\n(Run `npm ci`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)',
); );
} }
dfnAssets.forEach(([s, d]) => { dfn.forEach(([s, rel]) => {
const d = path.join(denoiseDir, rel);
fs.mkdirSync(path.dirname(d), { recursive: true }); fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d); fs.copyFileSync(s, d);
}); });