Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd760ebc3c |
@@ -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>>(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Binary file not shown.
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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(':');
|
||||||
|
|||||||
@@ -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's server isn't responding. Messages will send when it'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
@@ -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 “{filterQuery.trim()}”
|
</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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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.`;
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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());
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user