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
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
9 changed files with 54 additions and 54 deletions
+22 -8
View File
@@ -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, isFromFilteredTimelineSet } from '../utils/room';
import { getStateEvent, getMemberName } 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,7 +502,6 @@ 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;
@@ -597,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
@@ -605,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)) {
@@ -614,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);
@@ -684,10 +696,12 @@ 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);
+4 -5
View File
@@ -90,7 +90,6 @@ import {
isMembershipChanged,
reactionOrEditEvent,
sendRoomEvent,
isFromFilteredTimelineSet,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics';
@@ -139,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';
@@ -411,9 +410,6 @@ 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) => {
@@ -2041,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 ||
@@ -14,7 +14,6 @@ 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[];
@@ -112,7 +111,6 @@ 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);
};
@@ -49,7 +49,6 @@ import {
getNotificationType,
getUnreadInfo,
isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room';
import { NotificationType } from '../../../types/matrix/room';
import { mxcUrlToHttp } from '../../utils/matrix';
@@ -685,7 +684,6 @@ 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 ||
@@ -1128,7 +1126,6 @@ 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(':');
-2
View File
@@ -24,7 +24,6 @@ import {
getUnreadInfo,
getUnreadInfos,
isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room';
import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
@@ -237,7 +236,6 @@ 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.
+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),
);
-20
View File
@@ -1,20 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { IRoomTimelineData } from 'matrix-js-sdk';
import { isFromFilteredTimelineSet } from './room';
const data = (filter: unknown): IRoomTimelineData =>
({
liveEvent: true,
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
}) as unknown as IRoomTimelineData;
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
assert.equal(isFromFilteredTimelineSet(undefined), false);
assert.equal(
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
false,
);
});
-12
View File
@@ -20,7 +20,6 @@ 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';
@@ -848,14 +847,3 @@ 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();