From 4650796120c95e737ab6b45575d060e5e9514a7c Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 20:29:50 -0400 Subject: [PATCH] fix(calls): no "Missed call" in voice rooms or space channels Joining a voice channel alone sends a call notification (m.rtc.notification) to the room, and the timeline rendered every unanswered one as "Missed call from X", so everyone in the channel saw a missed call from someone who had just joined it. Ringing already excluded these rooms; the missed-call line didn't. utils/callRing.ts (unit-tested) holds the one rule for both: only DMs and invite-only groups outside any space ring or show "Missed call"; voice rooms, space channels, restricted and public rooms don't. Verified headless with the same two notifications from Bob: the Voice Lounge chat showed 2 "Missed call" lines before, 0 after; a small invite-only group still shows its missed call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/CallEmbedProvider.tsx | 11 +++------ src/app/features/room/RoomTimeline.tsx | 4 ++++ src/app/utils/callRing.test.ts | 29 ++++++++++++++++++++++++ src/app/utils/callRing.ts | 18 +++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 src/app/utils/callRing.test.ts create mode 100644 src/app/utils/callRing.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index c2f38bbdb..0660d27e8 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -20,7 +20,6 @@ import { import { EventTimelineSetHandlerMap, EventType, - JoinRule, RelationType, Room, RoomEvent, @@ -72,7 +71,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion'; import { useSetting } from '../state/hooks/settings'; import { useCallPreferences } from '../state/hooks/callPreferences'; import { settingsAtom } from '../state/settings'; -import { getStateEvent, getStateEvents, getMemberName } from '../utils/room'; +import { getStateEvent, getMemberName } from '../utils/room'; +import { isRingableRoom } from '../utils/callRing'; import { StateEvent } from '../../types/matrix/room'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; @@ -581,12 +581,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) // Only ring in rooms where the call button is visible: DMs or invite-only rooms // with no space parent. Persistent voice rooms (call rooms), space channels, // restricted rooms, and public rooms must never trigger ringing. - if (room.isCallRoom()) return; - const isDirect = directs.has(room.roomId); - const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0; - const joinRule = room.getJoinRule(); - const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite; - if (!isDirect && !isPrivateInviteGroup) return; + if (!isRingableRoom(room, directs.has(room.roomId))) return; const info: IncomingCallInfo = { room, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index ab0c76973..fd69479c4 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -138,6 +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 { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; @@ -2030,6 +2031,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli [EventType.RTCNotification]: (mEventId, mEvent, item) => { const senderId = mEvent.getSender() ?? ''; if (!senderId || senderId === mx.getUserId()) return null; + // 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; const content = mEvent.getContent(); const mention = content['m.mentions']?.room || diff --git a/src/app/utils/callRing.test.ts b/src/app/utils/callRing.test.ts new file mode 100644 index 000000000..22a5c0c1d --- /dev/null +++ b/src/app/utils/callRing.test.ts @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Room } from 'matrix-js-sdk'; +import { isRingableRoom } from './callRing'; + +const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room => + ({ + isCallRoom: () => !!opts.callRoom, + getJoinRule: () => opts.joinRule ?? 'invite', + getLiveTimeline: () => ({ + getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }), + }), + }) as unknown as Room; + +test('voice rooms never ring or show "missed call", even as DMs', () => { + assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false); + assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false); +}); + +test('DMs and invite-only groups outside a space ring', () => { + assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true); + assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true); +}); + +test('space channels, public and restricted rooms do not ring', () => { + assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false); + assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false); + assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false); +}); diff --git a/src/app/utils/callRing.ts b/src/app/utils/callRing.ts new file mode 100644 index 000000000..c705a3df8 --- /dev/null +++ b/src/app/utils/callRing.ts @@ -0,0 +1,18 @@ +import { JoinRule, Room } from 'matrix-js-sdk'; +import { StateEvent } from '../../types/matrix/room'; +import { getStateEvents } from './room'; + +/** + * Whether a call notification in this room may ring — and, by the same rule, + * whether an unanswered one may show as "Missed call" (Gitea #162 follow-up). + * Only DMs and invite-only groups outside any space ring, i.e. rooms where the + * call button is shown. Persistent voice rooms, space channels, restricted and + * public rooms never do: joining a voice channel alone is normal and isn't a + * "call" anyone missed. + */ +export const isRingableRoom = (room: Room, isDirect: boolean): boolean => { + if (room.isCallRoom()) return false; + if (isDirect) return true; + const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0; + return !isSpaceChild && room.getJoinRule() === JoinRule.Invite; +};