Compare commits

..
Author SHA1 Message Date
jared 9c433d9d77 Merge branch 'lotus' into fix/decline-references-notification
CI / Build & Quality Checks (pull_request) Successful in 2m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m45s
2026-10-17 19:29:23 -04:00
jared f84c0d6e9e Merge pull request 'fix(dialogs): Enter in Poll, Schedule, Report dialogs no longer discards input (#315)' (#369) from fix/dialog-enter-closes into lotus
CI / Build & Quality Checks (push) Successful in 3m14s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 17s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 18:35:54 -04:00
Lotus CIandClaude Opus 5.5 a425aa3572 fix(calls): Reject sends the decline to the ring notification (#313)
CI / Build & Quality Checks (pull_request) Successful in 3m8s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Failing after 18m4s
handleReject related the MSC4310 decline to the caller's call.member event
(the notification's reference), but MSC4310 and Element Call match a
decline against the rtc.notification event itself, so the caller kept
"Calling…" for the full 90 s after being told "Declined", and declines
from other clients (correctly referencing the notification) didn't stop
Lotus ringing on other devices.

- The incoming call keeps the notification's own event id; Reject relates
  the decline to it.
- The "declined on another device" dismissal and declinedRing accept
  either id (older Lotus builds referenced the membership).
- The missed-call line is skipped for a call we declined.

Verified headless (DM, Bob rings, Reject): the decline used to reference
the membership; it now references the notification. Unit test added.

Fixes #313

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:01:06 -04:00
jared f0b7400d1b Merge branch 'lotus' into fix/dialog-enter-closes
CI / Build & Quality Checks (pull_request) Successful in 3m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m57s
2026-10-17 17:53:08 -04:00
Lotus CIandClaude Opus 5.5 b6f9af3f58 fix(dialogs): Enter in Poll, Schedule, Report dialogs no longer discards input (#315)
CI / Build & Quality Checks (pull_request) Successful in 3m58s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Failing after 18m27s
Several dialogs are <Box as="form"> with the header ✕ (folds IconButton,
no type attribute, so a submit button) as the form's first button. On
implicit submission, Enter in a text field "clicked" ✕, closing the dialog
and throwing away what was typed (a poll's question and answers, a
schedule, a report reason).

The header close/cancel IconButtons now have type="button": Create Poll,
Schedule Message, Report User, Report Room, Account Data editor, State
Event editor, device verification setup, and the message delete/report
dialogs.

Verified headless: in Create Poll, typing a question and pressing Enter
used to close the dialog; now it stays open with the question kept.

Fixes #315

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:58:05 -04:00
18 changed files with 117 additions and 146 deletions
+1 -1
View File
@@ -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>
+21 -5
View File
@@ -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">
@@ -596,6 +602,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 +611,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 +623,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);
@@ -685,7 +698,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
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>
@@ -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>
+4 -5
View File
@@ -80,7 +80,7 @@ import {
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { clearMuteTimer, scheduleMuteTimer } from './muteTimers';
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
@@ -381,10 +381,9 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
prevMode,
).catch(() => {});
if (durationMs !== null) {
await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {});
} else {
// An indefinite mute replaces any timed one.
await clearMuteTimer(mx, room.roomId);
scheduleMuteTimer(room.roomId, durationMs, () => {
unmuteRoom(mx, room.roomId);
});
}
requestClose();
},
+1 -14
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers';
import { shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
@@ -18,16 +18,3 @@ test('does not reset when the user switched to Special messages during the mute
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => {
const entries = [
{ roomId: '!a:x', unmuteAt: 1000 },
{ roomId: '!b:x', unmuteAt: 2000 },
];
assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries);
assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [
{ roomId: '!b:x', unmuteAt: 5 },
]);
assert.deepEqual(parseMuteTimers(undefined), []);
assert.deepEqual(parseMuteTimers({ timers: null }), []);
});
+13 -74
View File
@@ -7,7 +7,7 @@ import {
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// Legacy localStorage key (before #333); migrated into account data once.
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
@@ -16,62 +16,19 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
/** Pure: the timers in an `io.lotus.mute_timers` account data content. */
export function parseMuteTimers(content: unknown): MuteTimerEntry[] {
const timers = (content as { timers?: unknown } | undefined)?.timers;
if (!timers || typeof timers !== 'object') return [];
return Object.entries(timers as Record<string, unknown>)
.filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1]))
.map(([roomId, unmuteAt]) => ({ roomId, unmuteAt }));
}
/** Pure: account data content for `entries`. */
export const muteTimersContent = (entries: MuteTimerEntry[]) => ({
timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])),
});
/**
* [Gitea #333] Timed mutes live in account data: the mute itself is a
* server-side push rule on every device, so the timer must be too — kept in one
* browser's localStorage, only that device ever unmuted (and never after a
* logout there).
*/
export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] {
return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent());
}
async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise<void> {
await mx.setAccountData(
AccountDataEvent.LotusMuteTimers as never,
muteTimersContent(entries) as never,
);
}
/** Move timers kept in this browser's localStorage (pre-#333) into account data. */
export async function migrateLocalMuteTimers(mx: MatrixClient): Promise<void> {
let legacy: MuteTimerEntry[] = [];
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
legacy = Array.isArray(parsed)
? parsed.filter(
(e): e is MuteTimerEntry =>
!!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number',
)
: [];
return Array.isArray(parsed) ? parsed : [];
} catch {
legacy = [];
}
if (legacy.length === 0) return;
const current = loadMuteTimers(mx);
const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))];
await saveMuteTimers(mx, merged);
try {
localStorage.removeItem(MUTE_TIMERS_KEY);
} catch {
/* ignore */
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
// Unset if the room's notification mode is still Mute at expiry time. If the user
// manually changed it (e.g. to All messages) while the timer was pending, leave
@@ -120,30 +77,12 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
RoomNotificationMode.Mute,
).catch(() => {});
}
await clearMuteTimer(mx, roomId);
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */
export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise<void> {
const timers = loadMuteTimers(mx);
if (!timers.some((e) => e.roomId === roomId)) return;
await saveMuteTimers(
mx,
timers.filter((e) => e.roomId !== roomId),
).catch(() => {});
}
/**
* Record when a timed mute ends. MuteTimerRestore (on every Lotus device)
* schedules the unmute from account data, so whichever is online at expiry
* lifts it.
*/
export async function scheduleMuteTimer(
mx: MatrixClient,
roomId: string,
durationMs: number,
): Promise<void> {
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId);
await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]);
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+4 -1
View File
@@ -138,7 +138,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';
@@ -2037,6 +2037,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>
+14 -2
View File
@@ -725,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>
@@ -865,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>
+21 -27
View File
@@ -58,8 +58,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import {
MAX_MUTE_TIMEOUT_MS,
MuteTimerEntry,
loadMuteTimers,
migrateLocalMuteTimers,
unmuteRoom,
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
@@ -91,7 +91,6 @@ import {
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
import { AccountDataEvent } from '../../../types/matrix/accountData';
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
@@ -370,35 +369,30 @@ function PresenceUpdater() {
function MuteTimerRestore() {
const mx = useMatrixClient();
// [Gitea #333] Timers are in account data, so a mute set on another device
// is lifted here too, and changes there (a new mute, a manual unmute)
// reschedule. Moves any timers this browser kept locally (pre-#333) first.
useEffect(() => {
migrateLocalMuteTimers(mx).catch(() => {});
}, [mx]);
const timers = loadMuteTimers();
if (timers.length === 0) return undefined;
const now = Date.now();
const pastDue: MuteTimerEntry[] = [];
const future: MuteTimerEntry[] = [];
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
pastDue.forEach((entry) => {
unmuteRoom(mx, entry.roomId);
});
const handles = future.map((entry) =>
setTimeout(
() => {
unmuteRoom(mx, entry.roomId);
},
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
),
);
useEffect(() => {
let handles: ReturnType<typeof setTimeout>[] = [];
const schedule = () => {
handles.forEach(clearTimeout);
const now = Date.now();
handles = loadMuteTimers(mx).map((entry) =>
setTimeout(
() => {
unmuteRoom(mx, entry.roomId);
},
Math.max(0, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS)),
),
);
};
const onAccountData = (event: MatrixEvent) => {
if (event.getType() === AccountDataEvent.LotusMuteTimers) schedule();
};
schedule();
mx.on(ClientEvent.AccountData, onAccountData);
return () => {
handles.forEach(clearTimeout);
mx.removeListener(ClientEvent.AccountData, onAccountData);
};
}, [mx]);
+3 -4
View File
@@ -64,10 +64,9 @@ const clearMsgDrafts = (): void => {
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
* swept with the rest — the server-side presence status survives, so a
* re-login loses nothing)
* - low-sensitivity UI/metadata residue (collapsed nav/space categories,
* `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here.
* Timed mutes moved to account data in [Gitea #333]; a leftover local
* `io.lotus.mute_timers` is migrated there on the next start.
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
* preferences, not swept here
*/
/**
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
+15
View File
@@ -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,
);
});
+13 -2
View File
@@ -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),
);
-4
View File
@@ -26,10 +26,6 @@ export enum AccountDataEvent {
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
LotusSettings = 'io.lotus.settings',
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
// device can lift a mute set on another.
LotusMuteTimers = 'io.lotus.mute_timers',
SecretStorageDefaultKey = 'm.secret_storage.default_key',
CrossSigningMaster = 'm.cross_signing.master',