Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e90c7bc4 | ||
|
|
c87fa4f6d5 | ||
|
|
5ecf4132f5 | ||
|
|
d412f9c56c | ||
|
|
ad5631819d | ||
|
|
83933fb5ac | ||
|
|
a7c9d15824 | ||
|
|
e6e52cdc37 | ||
|
|
4ab5a8fd06 | ||
|
|
4e30ae763e | ||
|
|
f9265d2d90 | ||
|
|
82c5728a96 | ||
|
|
dd6cf156ba | ||
|
|
e94cdfe5dc | ||
|
|
bb2ace82eb | ||
|
|
906a034e26 | ||
|
|
8e7127a350 | ||
|
|
f30a65eb74 | ||
|
|
0a2f18e3d5 | ||
|
|
0d08ba0a55 | ||
|
|
6ae4d7adc0 | ||
|
|
b855eb505d | ||
|
|
f7bdd2855a | ||
|
|
26cf1204b5 | ||
|
|
e16c923bea | ||
|
|
cc8e7444f6 | ||
|
|
f9172cc339 | ||
|
|
8bbb436ca7 | ||
|
|
4ddcda122a | ||
|
|
9c433d9d77 | ||
|
|
e8fd0a1112 | ||
|
|
00e27a24e6 | ||
|
|
46d6570df3 | ||
|
|
d7ea408fd0 | ||
|
|
d3f01b5116 | ||
|
|
f84c0d6e9e | ||
|
|
1c62fb4722 | ||
|
|
a6c7333ea3 | ||
|
|
a425aa3572 | ||
|
|
f0b7400d1b | ||
|
|
d1270956ea | ||
|
|
3878a85339 | ||
|
|
9edb76835c | ||
|
|
1784d40ba1 | ||
|
|
76ef8a52c7 | ||
|
|
a3ef2b4098 | ||
|
|
0c5cf4109a | ||
|
|
39f74d51e6 | ||
|
|
36e697613b | ||
|
|
2d31312a73 | ||
|
|
57036bb6a4 | ||
|
|
c6419044cb | ||
|
|
29f6a35bfd | ||
|
|
78691f1fff | ||
|
|
a86947be48 | ||
|
|
37372b215a | ||
|
|
b6f9af3f58 | ||
|
|
5b5fbdb7b7 | ||
|
|
8b2322f751 | ||
|
|
02950499ea | ||
|
|
bbb2034080 |
@@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => {
|
||||
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 }) => {
|
||||
const room = await createRoom(alice, 'Scroll Room');
|
||||
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
||||
|
||||
@@ -29,6 +29,32 @@ async function settled(page: Page) {
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
|
||||
async function mockAnnouncement(page: Page) {
|
||||
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
|
||||
const res = await route.fetch();
|
||||
const cfg = await res.json();
|
||||
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
|
||||
await route.fulfill({ response: res, json: cfg });
|
||||
});
|
||||
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
|
||||
const cors = { 'access-control-allow-origin': '*' };
|
||||
if (route.request().url().includes('/heartbeat/')) {
|
||||
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
headers: cors,
|
||||
json: {
|
||||
config: { slug: 'matrix' },
|
||||
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
|
||||
maintenanceList: [],
|
||||
publicGroupList: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('security nudge (#110, #123)', () => {
|
||||
let user: TestUser;
|
||||
|
||||
@@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => {
|
||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
||||
test.setTimeout(200_000);
|
||||
await mockAnnouncement(page);
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
await settled(page);
|
||||
// The announcement wins the one strip slot until it's dismissed.
|
||||
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
|
||||
await expect(announcement).toBeVisible();
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
await announcement.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await page.reload();
|
||||
await settled(page);
|
||||
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
|
||||
});
|
||||
|
||||
test('"Not now" snoozes it across a reload', async ({ page }) => {
|
||||
test.setTimeout(200_000);
|
||||
await loginUI(page, user);
|
||||
|
||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.22",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
|
||||
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
|
||||
"version": "0.25.0-lotus.23",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
|
||||
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -297,7 +297,7 @@ export function AccountDataEditor({
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -73,7 +73,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getMemberName } from '../utils/room';
|
||||
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
@@ -111,6 +111,8 @@ type IncomingCallInfo = {
|
||||
intent?: string;
|
||||
notificationType: RTCNotificationType;
|
||||
refEventId: string;
|
||||
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
|
||||
notificationEventId?: string;
|
||||
};
|
||||
type IncomingCallProps = {
|
||||
dm: boolean;
|
||||
@@ -283,7 +285,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
onClick={() =>
|
||||
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||
}
|
||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
@@ -455,7 +459,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
onClick={() =>
|
||||
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||
}
|
||||
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -496,6 +502,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
@@ -596,6 +603,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
: undefined,
|
||||
notificationType,
|
||||
refEventId,
|
||||
notificationEventId: event.getId(),
|
||||
};
|
||||
|
||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||
@@ -604,7 +612,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
// 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.
|
||||
const myUserId = mx.getSafeUserId();
|
||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||
if (
|
||||
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||
hasActiveCallMembership(room, myUserId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) {
|
||||
@@ -613,7 +624,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (Date.now() >= senderTs + lifetime) return;
|
||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||
if (
|
||||
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||
hasActiveCallMembership(room, myUserId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||
@@ -683,9 +697,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||
const related = event.getRelation()?.event_id;
|
||||
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
||||
// [Gitea #313] Correct clients reference the notification; older Lotus
|
||||
// builds referenced the call membership. Accept both.
|
||||
if (!related || related === refEventId || related === callInfo.notificationEventId)
|
||||
dismiss(); // declined elsewhere
|
||||
}
|
||||
};
|
||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||
|
||||
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Setup Device Verification
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Reset Device Verification
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef, useCallback } from 'react';
|
||||
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
@@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../hooks/useModalStyle';
|
||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||
import { InfoCard } from './info-card';
|
||||
import { ScheduledMessage } from '../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
@@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
|
||||
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
// [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]),
|
||||
// [Gitea #322] Scheduled messages live on the server and still send after
|
||||
// logout, where no client would show them. Say so, and offer to cancel.
|
||||
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchScheduledMessages(mx)
|
||||
.then((list) => alive && setScheduled(list))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [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;
|
||||
@@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
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>
|
||||
{logoutState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
<Box direction="Column" gap="200">
|
||||
<Button
|
||||
variant="Critical"
|
||||
onClick={logout}
|
||||
onClick={() => logout()}
|
||||
disabled={ongoingLogout}
|
||||
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
||||
>
|
||||
<Text size="B400">Logout</Text>
|
||||
<Text size="B400">
|
||||
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
|
||||
</Text>
|
||||
</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}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
|
||||
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
data.allowFederation,
|
||||
data.additionalCreators,
|
||||
),
|
||||
power_level_content_override:
|
||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
||||
// [Gitea #328] Every non-space room gets call.member at user level, not just
|
||||
// voice rooms: the call button is shown in private group rooms too, and with
|
||||
// Synapse's state_default (50) only admins could start or join a call there
|
||||
// ("You don't have permission to join!").
|
||||
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
|
||||
initial_state: initialState,
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
@@ -612,7 +612,13 @@ export function EmojiBoard({
|
||||
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
|
||||
escapeDeactivates: stopPropagation,
|
||||
// [Gitea #304] The board opens with focus in its search field, where
|
||||
// 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
|
||||
|
||||
@@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
|
||||
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
@@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
const [linkCopied, setLinkCopied] = 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 roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
return getMatrixToRoom(roomIdOrAlias, viaServers);
|
||||
return room.isSpaceRoom()
|
||||
? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers)
|
||||
: getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers);
|
||||
})();
|
||||
|
||||
const handleCopyLink = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getMemberName, sendRoomEvent } from '../../../utils/room';
|
||||
import {
|
||||
@@ -99,7 +99,21 @@ export function PollContent({
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const roomId = room.roomId;
|
||||
const eventId = mEvent.getId();
|
||||
// [Gitea #284] A poll you just created starts with its local-echo id
|
||||
// (`~!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 parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||
@@ -192,8 +206,9 @@ export function PollContent({
|
||||
const isMultiple = maxSelections > 1;
|
||||
|
||||
const showResults = resultsVisible(isUndisclosed, isEnded);
|
||||
const canVote = !!roomId && !!eventId && !isEnded;
|
||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
|
||||
const canEnd =
|
||||
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
|
||||
const myVotes = pending ?? tally.myVotes;
|
||||
const { counts, total, voters } = tally;
|
||||
@@ -219,25 +234,34 @@ export function PollContent({
|
||||
setPending(next);
|
||||
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||
// accepts both namespaces).
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
// The SDK can throw synchronously (e.g. a relation to a pending event).
|
||||
try {
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
} catch {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndPoll = () => {
|
||||
if (!roomId || !eventId || ending) return;
|
||||
setEnding(true);
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
try {
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
} catch {
|
||||
setEnding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
useCallJoined,
|
||||
useCallEmbedPlacementSync,
|
||||
useCallLoadError,
|
||||
useCallStart,
|
||||
} from '../../hooks/useCallEmbed';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { PrescreenControls } from './PrescreenControls';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -160,6 +161,16 @@ function CallLoadErrorMessage() {
|
||||
// prescreen, where they can choose to join again.
|
||||
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 (
|
||||
<Box
|
||||
className={css.CallViewContent}
|
||||
@@ -173,6 +184,9 @@ function CallLoadErrorMessage() {
|
||||
The call failed to load. Check your connection and try again.
|
||||
</Text>
|
||||
<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}>
|
||||
<Text size="B400">Back</Text>
|
||||
</Button>
|
||||
|
||||
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
Binary file not shown.
@@ -40,6 +40,7 @@ import { creatorsSupported } from '../../../utils/matrix';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { stateToCarry } from '../../../utils/roomUpgradeState';
|
||||
|
||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const [upgradeState, upgrade] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (version: string, newAdditionalCreators?: string[]) => {
|
||||
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
|
||||
new_version: version,
|
||||
additional_creators: newAdditionalCreators,
|
||||
});
|
||||
// [Gitea #327] Synapse doesn't copy Lotus room state (voice limit, call
|
||||
// policy, soundboard, retention, role tags, emoji packs) into the
|
||||
// replacement room: read it first, re-create it after the upgrade.
|
||||
const carried = stateToCarry(
|
||||
Array.from(room.currentState.events.values()).flatMap((byKey) =>
|
||||
Array.from(byKey.values()),
|
||||
),
|
||||
);
|
||||
const res = await mx.http.authedRequest<{ replacement_room?: string }>(
|
||||
Method.Post,
|
||||
`/rooms/${room.roomId}/upgrade`,
|
||||
undefined,
|
||||
{
|
||||
new_version: version,
|
||||
additional_creators: newAdditionalCreators,
|
||||
},
|
||||
);
|
||||
const newRoomId = res?.replacement_room;
|
||||
if (!newRoomId || carried.length === 0) return;
|
||||
const results = await Promise.allSettled(
|
||||
carried.map((st) =>
|
||||
mx.http.authedRequest(
|
||||
Method.Put,
|
||||
`/rooms/${encodeURIComponent(newRoomId)}/state/${encodeURIComponent(st.type)}/${encodeURIComponent(st.stateKey)}`,
|
||||
undefined,
|
||||
st.content,
|
||||
),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
if (failed > 0) {
|
||||
throw new Error(
|
||||
`Room upgraded, but ${failed} of ${carried.length} Lotus settings (voice limit, call policy, soundboard, retention…) could not be copied to the new room.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[mx, room],
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { MsgTypeFilter } from './useMessageSearch';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
||||
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
||||
@@ -137,14 +138,19 @@ type SelectRoomButtonProps = {
|
||||
onChange: (rooms?: string[]) => void;
|
||||
};
|
||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||
|
||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||
(rId) => mx.getRoom(rId)?.name ?? rId,
|
||||
[mx],
|
||||
(rId) => {
|
||||
const name = mx.getRoom(rId)?.name ?? rId;
|
||||
// [Gitea #336] match the personal "Rename for me" name too
|
||||
return localNames[rId] ? [localNames[rId], name] : name;
|
||||
},
|
||||
[mx, localNames],
|
||||
);
|
||||
|
||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
||||
}
|
||||
>
|
||||
<Text truncate size="T300">
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
@@ -701,6 +707,7 @@ export function SearchFilters({
|
||||
pinnedOnly,
|
||||
onTogglePinnedOnly,
|
||||
}: SearchFiltersProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
@@ -748,7 +755,7 @@ export function SearchFilters({
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">{room.name}</Text>
|
||||
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
room: Room;
|
||||
@@ -73,6 +74,7 @@ export function SearchResultGroup({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: SearchResultGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||
@@ -212,7 +214,7 @@ export function SearchResultGroup({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
foldEdits,
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
@@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
|
||||
['$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,10 +96,58 @@ export const filterGroupsByDateRange = (
|
||||
.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 groups: ResultGroup[] = [];
|
||||
|
||||
results.forEach((item) => {
|
||||
foldEdits(results).forEach((item) => {
|
||||
const roomId = item.result.room_id;
|
||||
const resultItem: ResultItem = {
|
||||
rank: item.rank,
|
||||
|
||||
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
|
||||
Create Poll
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
||||
Report Room
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
||||
Report User
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -160,6 +160,7 @@ import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { getThreadDraftKey } from '../../state/room/thread';
|
||||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||||
import { plaintextCachesWiped } from '../../state/plaintextCaches';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -562,6 +563,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
// Persist the current editor state to the draft atom + localStorage; an empty
|
||||
// editor clears both so the draft indicators never claim a draft that isn't there.
|
||||
const persistDraft = useCallback(() => {
|
||||
// [Gitea #319] Logout already wiped the drafts; don't write this one back.
|
||||
if (plaintextCachesWiped()) return;
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
sendRoomEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { tick } from '../../utils/haptics';
|
||||
@@ -121,7 +122,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { RetentionContent, isExpired } from '../../utils/retention';
|
||||
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
@@ -138,7 +139,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { isRingableRoom } from '../../utils/callRing';
|
||||
import { declinedRing, isRingableRoom } from '../../utils/callRing';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
@@ -410,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
||||
data,
|
||||
) => {
|
||||
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);
|
||||
};
|
||||
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
||||
@@ -494,7 +498,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||
// re-renders. `undefined` = no policy.
|
||||
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
||||
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
|
||||
const retentionMs = effectiveRetentionMs(
|
||||
retentionEvent?.getContent<RetentionContent>().max_lifetime,
|
||||
);
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
@@ -2034,6 +2041,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||
// channels, where starting a call is just joining the channel.
|
||||
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 mention =
|
||||
content['m.mentions']?.room ||
|
||||
|
||||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
||||
import {
|
||||
cancelScheduledMessage,
|
||||
fetchScheduledMessages,
|
||||
groupScheduledByRoom,
|
||||
sendScheduledMessageNow,
|
||||
} from '../../utils/scheduledMessages';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
||||
@@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return undefined;
|
||||
|
||||
@@ -31,12 +31,13 @@ import React, {
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useHover, useFocusWithin } from 'react-aria';
|
||||
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, MatrixEventEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import classNames from 'classnames';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
@@ -114,6 +115,20 @@ function DeliveryStatus({
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const offline = useAtomValue(sendOfflineAtom);
|
||||
// [Gitea #306] A local echo's status changes (SENDING → SENT → confirmed,
|
||||
// NOT_SENT → retried) don't re-render the timeline, so the last message in a
|
||||
// quiet room kept "Sending..." (or a stale "Failed to send") indefinitely.
|
||||
// Re-render on the event's own status updates.
|
||||
const [, setStatusTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const bump = () => setStatusTick((t) => t + 1);
|
||||
mEvent.on(MatrixEventEvent.Status, bump);
|
||||
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||
return () => {
|
||||
mEvent.off(MatrixEventEvent.Status, bump);
|
||||
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||
};
|
||||
}, [mEvent]);
|
||||
const { status } = mEvent;
|
||||
if (status === null) return null; // confirmed by server — read receipts take over
|
||||
let iconSrc: IconSrc;
|
||||
@@ -710,7 +725,13 @@ export const MessageDeleteItem = as<
|
||||
Delete Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -850,7 +871,13 @@ export const MessageReportItem = as<
|
||||
Report Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rowWidth = container.getBoundingClientRect().width;
|
||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
||||
// [Gitea #305] The width the chips actually get (content box). The border
|
||||
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
|
||||
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
|
||||
const cs = getComputedStyle(container);
|
||||
const rowWidth =
|
||||
container.clientWidth -
|
||||
(parseFloat(cs.paddingLeft) || 0) -
|
||||
(parseFloat(cs.paddingRight) || 0);
|
||||
const gap = parseFloat(cs.columnGap) || 8;
|
||||
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
||||
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
style={{
|
||||
...props.style,
|
||||
...(collapsed && rowHeight
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
|
||||
// margins so the timeline's marginTop survives (Gitea #305: the
|
||||
// `margin` shorthand overrode it and, once removed, cleared it).
|
||||
{
|
||||
maxHeight: rowHeight + 4,
|
||||
overflow: 'hidden',
|
||||
padding: 2,
|
||||
marginLeft: -2,
|
||||
marginRight: -2,
|
||||
marginBottom: -2,
|
||||
marginTop:
|
||||
props.style?.marginTop !== undefined
|
||||
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
|
||||
: -2,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
ref={(el) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { getMemberName } from '../../../utils/room';
|
||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type ThreadPanelHeaderProps = {
|
||||
room: Room;
|
||||
@@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||
const { format } = useTimestampFormatter();
|
||||
// [Gitea #165] Who started the thread and when — the first thing you want
|
||||
@@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
|
||||
Thread
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
@@ -235,6 +236,7 @@ export type ThreadsListPanelProps = {
|
||||
onOpenThread: (threadId: string) => void;
|
||||
};
|
||||
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const handleJump = useCallback(
|
||||
@@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
||||
Threads
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{totalUnread > 0 && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
|
||||
type PendingAsk = {
|
||||
requests: WidgetPermissionRequest[];
|
||||
@@ -111,6 +112,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
};
|
||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||
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);
|
||||
else feed(ev);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||
import { serverStatusAtom } from '../../state/serverStatus';
|
||||
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||
import { pickBanner } from '../../utils/kumaStatus';
|
||||
import {
|
||||
dismissNudge,
|
||||
@@ -66,6 +66,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
const backupInfo = useKeyBackupInfo(crypto);
|
||||
const backupActive = useBackupActive(crypto);
|
||||
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
||||
const statusDismissed = useAtomValue(statusDismissedAtom);
|
||||
const requestSettings = useSetAtom(settingsRequestAtom);
|
||||
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
||||
// never stacks under the "Connecting…" strip at startup.
|
||||
@@ -91,7 +92,11 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
});
|
||||
|
||||
// One strip at a time: outages, maintenance and a lost connection win.
|
||||
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
|
||||
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
||||
// on screen and must not keep the nudge hidden for as long as it's posted.
|
||||
const otherStrip =
|
||||
syncLost ||
|
||||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||
return null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
@@ -7,20 +7,9 @@ import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { useServerStatus } from '../../hooks/useServerStatus';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { serverStatusAtom } from '../../state/serverStatus';
|
||||
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
||||
|
||||
const DISMISS_KEY = 'lotus-status-banner-dismissed';
|
||||
const DISMISS_MAX = 50;
|
||||
|
||||
const readDismissed = (): string[] => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
|
||||
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
|
||||
|
||||
const bannerIcon = (b: StripContent): IconSrc => {
|
||||
if (b.kind === 'maintenance') return Icons.Setting;
|
||||
@@ -163,7 +152,12 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
||||
});
|
||||
}, []),
|
||||
);
|
||||
const syncLost = lostAt !== null;
|
||||
// [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall
|
||||
// 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 setAtom = useSetAtom(serverStatusAtom);
|
||||
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
|
||||
@@ -173,22 +167,14 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
||||
/** The logged-in app's status strip. */
|
||||
export function ServerStatusBanner() {
|
||||
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
||||
const [dismissed, setDismissed] = useState(readDismissed);
|
||||
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
|
||||
const banner = pickBanner(status, {
|
||||
syncLost,
|
||||
where: 'client',
|
||||
dismissed: new Set(dismissed),
|
||||
});
|
||||
if (!banner) return null;
|
||||
const dismiss = () => {
|
||||
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// storage unavailable — dismissed for this session only
|
||||
}
|
||||
setDismissed(next);
|
||||
};
|
||||
const dismiss = () => dismissKey(banner.key);
|
||||
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
CALL_SESSION_HEARTBEAT_MS,
|
||||
clearCallSession,
|
||||
decideRejoin,
|
||||
othersInCall,
|
||||
rejoinToastBody,
|
||||
readCallSession,
|
||||
writeCallSession,
|
||||
} from '../utils/callRejoin';
|
||||
@@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
};
|
||||
dismissToast(TOAST_ID);
|
||||
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 {
|
||||
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
||||
} catch {
|
||||
@@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
.filter((m) => m.userId === mx.getUserId())
|
||||
.map((m) => m.deviceId),
|
||||
memberCount: memberships.length,
|
||||
dm: latest.current.directs.has(room.roomId),
|
||||
});
|
||||
// The RTC session fills in a moment after sync; keep waiting while it is
|
||||
// empty unless this is the final (timed-out) look.
|
||||
@@ -152,7 +176,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
id: TOAST_ID,
|
||||
iconSrc: Icons.Phone,
|
||||
displayName: 'Rejoin voice?',
|
||||
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
|
||||
body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
|
||||
roomName: room.name ?? '',
|
||||
roomId: room.roomId,
|
||||
sticky: true,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||
import { manualDndAtom } from '../state/manualDnd';
|
||||
@@ -6,6 +7,8 @@ import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const QUIET_TICK_MS = 30_000;
|
||||
|
||||
export type NotificationsQuietInputs = {
|
||||
focusAssistActive: boolean;
|
||||
manualDnd: boolean;
|
||||
@@ -40,6 +43,15 @@ export function isNotificationsQuiet({
|
||||
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
|
||||
// components.
|
||||
export function useNotificationsQuiet(): boolean {
|
||||
// [Gitea #325] The quiet-hours window and the snooze expiry depend on the
|
||||
// clock, but the long-lived notifiers using this hook rarely re-render, so a
|
||||
// window starting (or a 30-minute pause ending) while the app stayed open was
|
||||
// never noticed. Re-evaluate on a tick, like the night-light overlay.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), QUIET_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||
const manualDnd = useAtomValue(manualDndAtom);
|
||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
|
||||
inter: `'InterVariable', ${EMOJI_FONTS}, sans-serif`,
|
||||
inter: `'Inter Variable', 'InterVariable', ${EMOJI_FONTS}, sans-serif`,
|
||||
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
|
||||
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
getNotificationType,
|
||||
getUnreadInfo,
|
||||
isNotificationEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { NotificationType } from '../../../types/matrix/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
@@ -68,7 +69,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
||||
import { useReminders } from '../../hooks/useReminders';
|
||||
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
||||
import {
|
||||
getRoomRetentionMs,
|
||||
getRoomRetentionSetAt,
|
||||
isExpired,
|
||||
retentionDeletesAllowed,
|
||||
} from '../../utils/retention';
|
||||
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||
@@ -679,6 +685,7 @@ function MessageNotifications() {
|
||||
data,
|
||||
) => {
|
||||
if (mx.getSyncState() !== 'SYNCING') return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
|
||||
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
||||
if (
|
||||
!room ||
|
||||
@@ -910,6 +917,9 @@ function RetentionSweeper() {
|
||||
mx.getRooms().forEach((room) => {
|
||||
const maxLifetime = getRoomRetentionMs(room);
|
||||
if (!maxLifetime) return;
|
||||
// [Gitea #298] Never delete under a policy that is less than a day old.
|
||||
const setAt = getRoomRetentionSetAt(room);
|
||||
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
@@ -1118,6 +1128,7 @@ function ClockSkewFeature() {
|
||||
data,
|
||||
) => {
|
||||
if (!data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// Only events our homeserver stamped: a federated event's
|
||||
// origin_server_ts is the other server's clock.
|
||||
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { serverStatusAtom } from '../../state/serverStatus';
|
||||
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
|
||||
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
|
||||
|
||||
type StateData = {
|
||||
current: SyncState | null;
|
||||
@@ -17,6 +18,7 @@ type SyncStatusProps = {
|
||||
};
|
||||
export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
const serverStatus = useAtomValue(serverStatusAtom);
|
||||
const stalled = useAtomValue(homeserverStalledAtom);
|
||||
const [stateData, setStateData] = useState<StateData>({
|
||||
current: null,
|
||||
previous: undefined,
|
||||
@@ -34,6 +36,35 @@ 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 (
|
||||
(stateData.current === SyncState.Prepared ||
|
||||
stateData.current === SyncState.Syncing ||
|
||||
@@ -57,13 +88,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);
|
||||
|
||||
if (stateData.current === SyncState.Reconnecting && !serverDown) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
|
||||
+185
-134
@@ -255,6 +255,8 @@ function HomeCustomSection({
|
||||
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||
});
|
||||
}, [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
|
||||
// virtualizer per section (a late-mounted one never measured its scroller).
|
||||
return (
|
||||
@@ -443,6 +445,26 @@ export function Home() {
|
||||
});
|
||||
}, [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({
|
||||
count: filteredFavoriteRooms.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@@ -580,7 +602,7 @@ export function Home() {
|
||||
/>
|
||||
</Box>
|
||||
</NavCategory>
|
||||
{favoriteRooms.length > 0 && (
|
||||
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
@@ -629,142 +651,171 @@ export function Home() {
|
||||
notificationPreferences={notificationPreferences}
|
||||
/>
|
||||
))}
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
{noFilterMatch && (
|
||||
<NavCategory>
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
Rooms
|
||||
</RoomNavCategoryButton>
|
||||
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-expanded={!!sortMenuAnchor}
|
||||
aria-haspopup="menu"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setSortMenuAnchor((current) => (current ? undefined : cords));
|
||||
}}
|
||||
aria-label="Sort rooms"
|
||||
>
|
||||
<Icon src={Icons.Sort} size="100" />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={sortMenuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSortMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
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
|
||||
onClick={() => {
|
||||
setHomeRoomSort('recent');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'recent' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
No rooms match “{filterQuery.trim()}”
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => setFilterQuery('')}
|
||||
>
|
||||
<Text size="B300">Clear filter</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</NavCategory>
|
||||
)}
|
||||
{(!filtering || filteredRooms.length > 0) && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
>
|
||||
Rooms
|
||||
</RoomNavCategoryButton>
|
||||
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-expanded={!!sortMenuAnchor}
|
||||
aria-haspopup="menu"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setSortMenuAnchor((current) => (current ? undefined : cords));
|
||||
}}
|
||||
aria-label="Sort rooms"
|
||||
>
|
||||
<Icon src={Icons.Sort} size="100" />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={sortMenuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSortMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
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 }}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Recent Activity
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('alpha');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'alpha' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
A → Z
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('unread');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'unread' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Unread First
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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 (
|
||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId,
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('recent');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'recent' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Recent Activity
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('alpha');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'alpha' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
A → Z
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('unread');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'unread' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Unread First
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
{lowPriorityRooms.length > 0 && (
|
||||
</>
|
||||
)}
|
||||
</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 (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={roomId}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId,
|
||||
)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
)}
|
||||
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
|
||||
@@ -98,6 +98,7 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type RoomNotificationsGroup = {
|
||||
roomId: string;
|
||||
@@ -223,6 +224,7 @@ function RoomNotificationsGroupComp({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: RoomNotificationsGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
@@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -91,6 +91,13 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
|
||||
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||||
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.call = call;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||
import { canDelegateCapability } from './utils';
|
||||
import { getCallPageUrl } from './callPageUrl';
|
||||
import { restrictWidgetMessages } from '../widgetTransport';
|
||||
import { isRingableRoom } from '../../utils/callRing';
|
||||
|
||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||
@@ -251,8 +252,18 @@ export class CallEmbed {
|
||||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
||||
}
|
||||
|
||||
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
|
||||
// (DMs, invite-only groups outside a space). In a voice channel or space
|
||||
// room the notification carries an @room mention, so every member got a
|
||||
// highlighted ping (and phone push) whenever a moderator hopped in, and
|
||||
// other clients showed it as a missed call.
|
||||
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
|
||||
if (CallEmbed.startingCall(intent)) {
|
||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||
let notify = 'none';
|
||||
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
|
||||
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
|
||||
}
|
||||
params.append('sendNotificationType', notify);
|
||||
}
|
||||
|
||||
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
} from 'matrix-js-sdk';
|
||||
import { getCallCapabilities } from './utils';
|
||||
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 {
|
||||
private allowedCapabilities: Set<Capability>;
|
||||
|
||||
@@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver {
|
||||
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> {
|
||||
await this.mx._unstable_restartScheduledDelayedEvent(delayId);
|
||||
await this.restartOnce(delayId, () =>
|
||||
this.mx._unstable_restartScheduledDelayedEvent(delayId, {
|
||||
localTimeoutMs: RESTART_TIMEOUT_MS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
|
||||
|
||||
@@ -26,7 +26,7 @@ const api = {
|
||||
: Object.getOwnPropertyDescriptor(target, key),
|
||||
});
|
||||
|
||||
const { clearPlaintextCaches } = await import('./plaintextCaches');
|
||||
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
||||
|
||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||
store.clear();
|
||||
@@ -94,3 +94,13 @@ test('clearPlaintextCaches removes the call-volume list and the settings-sync st
|
||||
assert.equal(store.has('settings-sync-meta:@me:server'), false);
|
||||
assert.equal(store.has('settings'), true, 'device preferences are kept');
|
||||
});
|
||||
|
||||
test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe (#296, #319)', () => {
|
||||
store.clear();
|
||||
store.set('searchCacheEnabled', 'true');
|
||||
store.set('mxjssdk_memory_filter_FILTER_SYNC_@me:server', '0');
|
||||
clearPlaintextCaches('@me:server');
|
||||
assert.equal(store.has('searchCacheEnabled'), false);
|
||||
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||||
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||||
});
|
||||
|
||||
@@ -112,7 +112,34 @@ const clearCallVolumesAndSyncMeta = (userId?: string): void => {
|
||||
});
|
||||
};
|
||||
|
||||
// [Gitea #319] Set once a logout has wiped the caches. Logout then reloads the
|
||||
// page, and the reload's `pagehide` made RoomInput flush the open composer's
|
||||
// draft straight back to localStorage; draft writes check this and stop.
|
||||
// Module state, so it resets with the new page.
|
||||
let wiped = false;
|
||||
export const plaintextCachesWiped = (): boolean => wiped;
|
||||
|
||||
/**
|
||||
* [Gitea #296] Device-wide flags/keys that carry the previous account's choice
|
||||
* or id: the encrypted-search opt-in (the next account would otherwise start
|
||||
* indexing decrypted text without opting in) and the SDK's per-user filter key.
|
||||
*/
|
||||
const clearAccountScopedFlags = (userId?: string): void => {
|
||||
const keys = [
|
||||
'searchCacheEnabled',
|
||||
...(userId ? [`mxjssdk_memory_filter_FILTER_SYNC_${userId}`] : []),
|
||||
];
|
||||
keys.forEach((key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clearPlaintextCaches = (userId?: string): void => {
|
||||
wiped = true;
|
||||
clearTranslationCache();
|
||||
clearScheduledMessages();
|
||||
clearRecentSearches();
|
||||
@@ -124,5 +151,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearCallSession();
|
||||
clearStatusMessage();
|
||||
clearCallVolumesAndSyncMeta(userId);
|
||||
clearAccountScopedFlags(userId);
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getUnreadInfo,
|
||||
getUnreadInfos,
|
||||
isNotificationEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { roomToParentsAtom } from './roomToParents';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
@@ -236,6 +237,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
data: IRoomTimelineData,
|
||||
) => {
|
||||
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
|
||||
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
||||
// never double-driven / mis-attributed to the main timeline.
|
||||
|
||||
@@ -21,8 +21,8 @@ const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
|
||||
/**
|
||||
* Global atom: Map<roomId, ScheduledMessage[]>
|
||||
* Stores all locally-tracked scheduled messages across rooms.
|
||||
* MSC4140 has no list endpoint, so we track them ourselves.
|
||||
* Backed by localStorage so scheduled messages survive page refreshes.
|
||||
* Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts
|
||||
* (Gitea #322); localStorage is a cache for the first paint.
|
||||
*/
|
||||
export const scheduledMessagesAtom = atom(
|
||||
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
|
||||
|
||||
@@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{
|
||||
syncLost: false,
|
||||
lostAt: null,
|
||||
});
|
||||
|
||||
const DISMISS_KEY = 'lotus-status-banner-dismissed';
|
||||
const DISMISS_MAX = 50;
|
||||
|
||||
const readDismissed = (): string[] => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
|
||||
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const baseDismissedAtom = atom<string[]>(readDismissed());
|
||||
|
||||
/**
|
||||
* [Gitea #307] Status strips the user dismissed (by banner key), shared so
|
||||
* the security nudge knows a dismissed announcement isn't on screen.
|
||||
* Persisted to localStorage; session-only if storage is unavailable.
|
||||
*/
|
||||
export const statusDismissedAtom = atom(
|
||||
(get) => get(baseDismissedAtom),
|
||||
(get, set, key: string) => {
|
||||
const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX);
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// storage unavailable — dismissed for this session only
|
||||
}
|
||||
set(baseDismissedAtom, next);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
|
||||
import {
|
||||
CALL_REJOIN_MAX_AGE_MS,
|
||||
decideRejoin,
|
||||
othersInCall,
|
||||
rejoinToastBody,
|
||||
RejoinContext,
|
||||
} from './callRejoin';
|
||||
|
||||
const base: RejoinContext = {
|
||||
record: {
|
||||
@@ -54,3 +60,21 @@ 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,6 +67,8 @@ export type RejoinContext = {
|
||||
ownMemberDevices: string[];
|
||||
/** Number of call memberships in the room, ours included. */
|
||||
memberCount: number;
|
||||
/** A DM call: rejoining one may ring the other person. */
|
||||
dm?: boolean;
|
||||
};
|
||||
|
||||
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
|
||||
@@ -83,5 +85,18 @@ export function decideRejoin(ctx: RejoinContext): RejoinDecision {
|
||||
// Nobody there any more (our own stale membership doesn't count).
|
||||
const others = ctx.memberCount - ctx.ownMemberDevices.length;
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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,3 +67,18 @@ 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, '$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,7 +40,18 @@ export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
|
||||
* 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.
|
||||
*/
|
||||
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
|
||||
export const declinedRing = (
|
||||
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
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
@@ -48,5 +59,5 @@ export const declinedRing = (room: Room, refEventId: string, myUserId: string):
|
||||
(ev) =>
|
||||
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
||||
ev.getSender() === myUserId &&
|
||||
ev.getRelation()?.event_id === refEventId,
|
||||
ids.has(ev.getRelation()?.event_id),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
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());
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
plain,
|
||||
DEFAULT_GROUPS,
|
||||
kumaBeatTime,
|
||||
kumaUrls,
|
||||
@@ -260,3 +261,27 @@ test('a dismissed calls-down banner lets the next one through, never hides serve
|
||||
'server-down',
|
||||
);
|
||||
});
|
||||
|
||||
test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', () => {
|
||||
assert.equal(
|
||||
plain('join #lotus-general or #announcements:lotusguild.org'),
|
||||
'join #lotus-general or #announcements:lotusguild.org',
|
||||
);
|
||||
assert.equal(
|
||||
plain('ping @some_user about snake_case and C#'),
|
||||
'ping @some_user about snake_case and C#',
|
||||
);
|
||||
assert.equal(
|
||||
plain('calls -> fixed, > 5 people, 5 * 3 = 15'),
|
||||
'calls -> fixed, > 5 people, 5 * 3 = 15',
|
||||
);
|
||||
assert.equal(
|
||||
plain('# Heading\n* item one\n- item two\n> quoted'),
|
||||
'Heading\nitem one\nitem two\nquoted',
|
||||
);
|
||||
assert.equal(plain('**bold**, *em*, _em_, `/sfu/get`'), 'bold, em, em, /sfu/get');
|
||||
assert.equal(
|
||||
plain('Read the <b>guide</b> <img src=x onerror=alert(1)> [docs](https://x.y)'),
|
||||
'Read the guide docs',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -240,12 +240,25 @@ const incidentTone = (style: string): BannerTone => {
|
||||
};
|
||||
|
||||
/** Markdown-ish incident text as plain text for the Details line. */
|
||||
const plain = (s: string): string =>
|
||||
/**
|
||||
* [Gitea #308] Markdown → plain text for the Details line. Strips only the
|
||||
* syntax: removing every `*_\`#>` also broke "#lotus-general", "snake_case",
|
||||
* "->" and "5 * 3". HTML tags are dropped (React escapes the text anyway).
|
||||
*/
|
||||
export const plain = (s: string): string =>
|
||||
s
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_`#>]/g, '')
|
||||
.replace(/\s+\n/g, '\n')
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
|
||||
.replace(/^[ \t]*>[ \t]?/gm, '')
|
||||
.replace(/^[ \t]*[*+-][ \t]+/gm, '')
|
||||
.replace(/`([^`\n]+)`/g, '$1')
|
||||
.replace(/\*\*(?=\S)([^*\n]*?\S)\*\*/g, '$1')
|
||||
.replace(/__(?=\S)([^_\n]*?\S)__/g, '$1')
|
||||
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1$2')
|
||||
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.trim();
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
|
||||
import {
|
||||
effectiveRetentionMs,
|
||||
isExpired,
|
||||
RETENTION_DELETE_GRACE_MS,
|
||||
RETENTION_MIN_MS,
|
||||
RETENTION_PRESETS,
|
||||
retentionDeletesAllowed,
|
||||
} from './retention';
|
||||
|
||||
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
||||
// retention window. It must be strict (> window, not >=) and a disabled policy
|
||||
@@ -40,3 +47,22 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
|
||||
'all presets above the floor',
|
||||
);
|
||||
});
|
||||
|
||||
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
|
||||
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
|
||||
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
|
||||
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
|
||||
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
|
||||
assert.equal(effectiveRetentionMs(0), undefined);
|
||||
assert.equal(effectiveRetentionMs(-5), undefined);
|
||||
assert.equal(effectiveRetentionMs('86400000'), undefined);
|
||||
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
|
||||
});
|
||||
|
||||
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
|
||||
const now = 1_000 * HOUR;
|
||||
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
|
||||
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
|
||||
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
|
||||
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
|
||||
});
|
||||
|
||||
@@ -20,13 +20,38 @@ export const RETENTION_PRESETS: RetentionPreset[] = [
|
||||
{ label: '1 Month', ms: 30 * DAY_MS },
|
||||
];
|
||||
|
||||
/**
|
||||
* [Gitea #298] A usable retention window, or `undefined`. Anything under
|
||||
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
|
||||
* from any client by anyone with state power) used to blank every timeline and
|
||||
* get opted-in users' messages redacted within a minute.
|
||||
*/
|
||||
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
|
||||
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
|
||||
? maxLifetime
|
||||
: undefined;
|
||||
|
||||
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
||||
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
||||
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
||||
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
|
||||
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
||||
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
|
||||
* policy for this long after it was set, so a mistaken or malicious change can
|
||||
* be reverted before anything is permanently redacted. (Hiding expired
|
||||
* messages locally is not destructive and applies at once.)
|
||||
*/
|
||||
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
|
||||
|
||||
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
|
||||
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
|
||||
|
||||
/** When the room's current retention policy was set (its state event's ts). */
|
||||
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
|
||||
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
|
||||
|
||||
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
||||
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
||||
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Room,
|
||||
RoomMember,
|
||||
TimelineEvents,
|
||||
IRoomTimelineData,
|
||||
} from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
|
||||
@@ -847,3 +848,14 @@ export const guessPerfectParent = (
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isCarriedStateType, stateToCarry } from './roomUpgradeState';
|
||||
|
||||
const ev = (type: string, content: Record<string, unknown>, stateKey = '') => ({
|
||||
getType: () => type,
|
||||
getStateKey: () => stateKey,
|
||||
getContent: () => content,
|
||||
});
|
||||
|
||||
test('Lotus room state is carried; state Synapse already copies is not (#327)', () => {
|
||||
assert.equal(isCarriedStateType('io.lotus.voice_limit'), true);
|
||||
assert.equal(isCarriedStateType('io.lotus.room_quality'), true);
|
||||
assert.equal(isCarriedStateType('io.lotus.soundboard'), true);
|
||||
assert.equal(isCarriedStateType('m.room.retention'), true);
|
||||
assert.equal(isCarriedStateType('in.cinny.room.power_level_tags'), true);
|
||||
assert.equal(isCarriedStateType('im.ponies.room_emotes'), true);
|
||||
assert.equal(isCarriedStateType('m.room.power_levels'), false);
|
||||
assert.equal(isCarriedStateType('m.room.name'), false);
|
||||
assert.equal(isCarriedStateType('m.room.member'), false);
|
||||
});
|
||||
|
||||
test('stateToCarry keeps state keys and skips emptied events', () => {
|
||||
const out = stateToCarry([
|
||||
ev('io.lotus.voice_limit', { max_users: 5 }),
|
||||
ev('io.lotus.soundboard', { clips: [1] }, 'pack-a'),
|
||||
ev('io.lotus.room_quality', {}),
|
||||
ev('m.room.name', { name: 'x' }),
|
||||
ev('m.room.retention', { max_lifetime: 604800000 }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
out.map((s) => `${s.type}|${s.stateKey}`),
|
||||
['io.lotus.voice_limit|', 'io.lotus.soundboard|pack-a', 'm.room.retention|'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* [Gitea #327] Room state Synapse does NOT copy into the replacement room on
|
||||
* `POST /rooms/{id}/upgrade` (it carries only power levels, join rules,
|
||||
* name/topic/avatar, history visibility, guest access, encryption, server ACL
|
||||
* and space parent/child). Lotus keeps call policy, voice limits, soundboard,
|
||||
* message retention, role tags and room emoji packs in state, so an upgrade
|
||||
* silently dropped all of them.
|
||||
*/
|
||||
const CARRIED_TYPES = new Set([
|
||||
'm.room.retention',
|
||||
'in.cinny.room.power_level_tags',
|
||||
'im.ponies.room_emotes',
|
||||
]);
|
||||
const CARRIED_PREFIXES = ['io.lotus.'];
|
||||
|
||||
export type StateToCarry = { type: string; stateKey: string; content: Record<string, unknown> };
|
||||
|
||||
export const isCarriedStateType = (type: string): boolean =>
|
||||
CARRIED_TYPES.has(type) || CARRIED_PREFIXES.some((p) => type.startsWith(p));
|
||||
|
||||
/** The state events to re-create in the replacement room (non-empty content only). */
|
||||
export const stateToCarry = (
|
||||
events: Array<{
|
||||
getType: () => string;
|
||||
getStateKey: () => string | undefined;
|
||||
getContent: () => Record<string, unknown>;
|
||||
}>,
|
||||
): StateToCarry[] =>
|
||||
events
|
||||
.filter((ev) => isCarriedStateType(ev.getType()))
|
||||
.map((ev) => ({
|
||||
type: ev.getType(),
|
||||
stateKey: ev.getStateKey() ?? '',
|
||||
content: ev.getContent(),
|
||||
}))
|
||||
.filter((s) => s.content && Object.keys(s.content).length > 0);
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
scheduleMessage,
|
||||
cancelScheduledMessage,
|
||||
restartScheduledMessage,
|
||||
parseDelayedEvents,
|
||||
groupScheduledByRoom,
|
||||
} from './scheduledMessages';
|
||||
|
||||
// Minimal MatrixClient stub: records every authedRequest call and returns a
|
||||
@@ -119,3 +121,61 @@ test('cancel/restart url-encode the delay id', async () => {
|
||||
await cancelScheduledMessage(mx, 'a/b c');
|
||||
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,4 +1,5 @@
|
||||
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
||||
import type { ScheduledMessage } from '../state/scheduledMessages';
|
||||
|
||||
/**
|
||||
* Schedule a message via MSC4140 (delayed messages).
|
||||
@@ -76,3 +77,61 @@ export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string)
|
||||
{ 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;
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@ export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
|
||||
* session. When that happens the device "resurrects" with a blank key store and the
|
||||
* client re-uploads a one-time key at an id Synapse already holds → a permanent
|
||||
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
|
||||
* undecryptable to-device/media keys). `persist()` grants based on engagement / PWA
|
||||
* install and shows no prompt; denial/absence is non-fatal.
|
||||
* undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once
|
||||
* (engagement / PWA install); Firefox shows a permission prompt and the promise
|
||||
* stays pending until it's answered. Denial/absence is non-fatal.
|
||||
*/
|
||||
const PERSIST_WAIT_MS = 1500;
|
||||
|
||||
export const requestPersistentStorage = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (!navigator.storage?.persist) return false;
|
||||
@@ -39,7 +42,15 @@ export const requestPersistentStorage = async (): Promise<boolean> => {
|
||||
|
||||
export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
// Protect the crypto store from eviction before we create and write to it.
|
||||
await requestPersistentStorage();
|
||||
// [Gitea #303] Never block startup on the answer: Firefox prompts and leaves
|
||||
// the promise pending until the user responds, which froze the app on
|
||||
// "Heating up". KE-1 only needs the request made; give it 1.5 s at most.
|
||||
await Promise.race([
|
||||
requestPersistentStorage(),
|
||||
new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => resolve(false), PERSIST_WAIT_MS);
|
||||
}),
|
||||
]);
|
||||
|
||||
const indexedDBStore = new IndexedDBStore({
|
||||
indexedDB: globalThis.indexedDB,
|
||||
|
||||
+3
-2
@@ -42,7 +42,8 @@
|
||||
|
||||
--font-emoji: 'Twemoji_DISABLED';
|
||||
--font-flags: 'TwemojiFlags_DISABLED';
|
||||
--font-secondary: 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif;
|
||||
--font-secondary:
|
||||
'Inter Variable', 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif;
|
||||
}
|
||||
|
||||
.dark-theme,
|
||||
@@ -66,7 +67,7 @@
|
||||
--mx-uc-7: hsl(243, 100%, 80%);
|
||||
--mx-uc-8: hsl(94, 100%, 80%);
|
||||
|
||||
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
|
||||
--font-secondary: 'Inter Variable', 'InterVariable', var(--font-emoji), sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
Reference in New Issue
Block a user