diff --git a/src/app/features/call-status/IncomingCallStrip.tsx b/src/app/features/call-status/IncomingCallStrip.tsx
new file mode 100644
index 00000000..f11c36b0
--- /dev/null
+++ b/src/app/features/call-status/IncomingCallStrip.tsx
@@ -0,0 +1,146 @@
+import React from 'react';
+import { Avatar, Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds';
+import classNames from 'classnames';
+import { useTranslation } from 'react-i18next';
+import { Room } from 'matrix-js-sdk';
+import { useSetAtom } from 'jotai';
+import { useNavigate } from 'react-router-dom';
+import * as css from './styles.css';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { UserAvatar } from '../../components/user-avatar';
+import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
+import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
+import { useDmCallStart } from '../../hooks/useDmCallStart';
+import { getDirectRoomPath } from '../../pages/pathUtils';
+import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
+import { getIncomingCallKey } from '../../utils/rtcNotification';
+
+const DECLINE_RETRY_DELAY_MS = 500;
+
+type IncomingCallStripProps = {
+ call: IncomingCall;
+ room: Room;
+};
+export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
+ const { t } = useTranslation();
+ const mx = useMatrixClient();
+ const navigate = useNavigate();
+ const useAuthentication = useMediaAuthentication();
+ const startDmCall = useDmCallStart();
+ const setIncoming = useSetAtom(incomingCallsAtom);
+
+ const senderId = call.notifEvent.getSender();
+ const displayName =
+ (senderId && getMemberDisplayName(room, senderId)) ||
+ (senderId && getMxIdLocalPart(senderId)) ||
+ senderId ||
+ t('Call.unknown_caller');
+ const avatarMxc = senderId ? getMemberAvatarMxc(room, senderId) : undefined;
+ const avatarUrl = avatarMxc
+ ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
+ : undefined;
+
+ const callKey = getIncomingCallKey(call.callId, call.roomId);
+
+ const handleAnswer = () => {
+ setIncoming({ type: 'REMOVE', key: callKey });
+ startDmCall(call.roomId);
+ navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)));
+ };
+
+ const handleDecline = async () => {
+ setIncoming({ type: 'REMOVE', key: callKey });
+ const evId = call.notifEvent.getId();
+ if (!evId) return;
+ try {
+ await mx.sendRtcDecline(call.roomId, evId);
+ } catch (err) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, DECLINE_RETRY_DELAY_MS);
+ });
+ try {
+ await mx.sendRtcDecline(call.roomId, evId);
+ } catch (retryErr) {
+ // eslint-disable-next-line no-console
+ console.warn('[call] sendRtcDecline failed after retry', retryErr);
+ }
+ }
+ };
+
+ return (
+
+
+
+ }
+ />
+
+
+
+ {displayName}
+
+
+ {t('Call.incoming')}
+
+
+
+
+
+ {t('Call.decline')}
+
+ }
+ >
+ {(anchorRef) => (
+
+
+
+ )}
+
+
+ {t('Call.answer')}
+
+ }
+ >
+ {(anchorRef) => (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/features/call-status/index.ts b/src/app/features/call-status/index.ts
index 99accf8b..49da8655 100644
--- a/src/app/features/call-status/index.ts
+++ b/src/app/features/call-status/index.ts
@@ -1 +1,2 @@
export * from './CallStatus';
+export * from './IncomingCallStrip';
diff --git a/src/app/features/call/IncomingCallToast.tsx b/src/app/features/call/IncomingCallToast.tsx
deleted file mode 100644
index 41be94f4..00000000
--- a/src/app/features/call/IncomingCallToast.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import { useAtomValue, useSetAtom } from 'jotai';
-import { Avatar, Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
-import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
-import { Room } from 'matrix-js-sdk';
-import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
-import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
-import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-import { UserAvatar } from '../../components/user-avatar';
-import { ContainerColor } from '../../styles/ContainerColor.css';
-import { useDmCallStart } from '../../hooks/useDmCallStart';
-import { getDirectRoomPath } from '../../pages/pathUtils';
-// eslint-disable-next-line import/no-relative-packages
-import RingSoundOgg from '../../../../public/sound/ring.ogg';
-// eslint-disable-next-line import/no-relative-packages
-import RingSoundMp3 from '../../../../public/sound/ring.mp3';
-
-type IncomingCallToastProps = {
- call: IncomingCall;
- room: Room;
-};
-
-function IncomingCallToast({ call, room }: IncomingCallToastProps) {
- const { t } = useTranslation();
- const mx = useMatrixClient();
- const navigate = useNavigate();
- const useAuthentication = useMediaAuthentication();
- const startDmCall = useDmCallStart();
- const setIncoming = useSetAtom(incomingCallsAtom);
-
- const senderId = call.notifEvent.getSender();
- const displayName =
- (senderId && getMemberDisplayName(room, senderId)) ||
- (senderId && getMxIdLocalPart(senderId)) ||
- senderId ||
- t('Call.unknown_caller');
- const avatarMxc = senderId ? getMemberAvatarMxc(room, senderId) : undefined;
- const avatarUrl = avatarMxc
- ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
- : undefined;
-
- const callKey = `call_${call.callId}_${call.roomId}`;
-
- const handleAnswer = () => {
- setIncoming({ type: 'REMOVE', key: callKey });
- startDmCall(call.roomId);
- navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)));
- };
-
- const handleDecline = async () => {
- setIncoming({ type: 'REMOVE', key: callKey });
- const evId = call.notifEvent.getId();
- if (!evId) return;
- try {
- await mx.sendRtcDecline(call.roomId, evId);
- } catch (err) {
- // best-effort — toast is gone either way
- // eslint-disable-next-line no-console
- console.warn('[call] sendRtcDecline failed', err);
- }
- };
-
- return (
-
-
- }
- />
-
-
-
- {displayName}
-
-
- {t('Call.incoming')}
-
-
-
- }
- >
- {t('Call.decline')}
-
- }
- >
- {t('Call.answer')}
-
-
-
- );
-}
-
-export function IncomingCallStack() {
- const mx = useMatrixClient();
- const incoming = useAtomValue(incomingCallsAtom);
- const audioRef = useRef(null);
-
- const hasIncoming = incoming.size > 0;
-
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
- if (hasIncoming) {
- audio.currentTime = 0;
- audio.play().catch(() => {
- // autoplay blocked — toast UI still visible
- });
- } else {
- audio.pause();
- audio.currentTime = 0;
- }
- }, [hasIncoming]);
-
- const entries = Array.from(incoming.values());
-
- return (
- <>
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
-
- {hasIncoming && (
-
- {entries.map((call) => {
- const room = mx.getRoom(call.roomId);
- if (!room) return null;
- return (
-
- );
- })}
-
- )}
- >
- );
-}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index d651dd11..bacf1a10 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -1022,6 +1022,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[string, MatrixEvent, number, EventTimelineSet, boolean]
>(
{
+ // Suppress DM-call service events from the timeline (§5.8). In encrypted
+ // DMs this takes effect after per-event decryption re-render via
+ // EncryptedContent; the first render shows the "not decrypted" placeholder.
+ // Hardcoded strings — migrate to EventType.RTCNotification/RTCDecline
+ // when MSC4075/MSC4310 stabilize (§5.19).
+ 'org.matrix.msc4075.rtc.notification': () => null,
+ 'org.matrix.msc4310.rtc.decline': () => null,
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
diff --git a/src/app/hooks/useCallerAutoHangup.ts b/src/app/hooks/useCallerAutoHangup.ts
new file mode 100644
index 00000000..7a24e6ba
--- /dev/null
+++ b/src/app/hooks/useCallerAutoHangup.ts
@@ -0,0 +1,121 @@
+// DM call auto-hangup.
+//
+// Scope despite the name: fires on any DM callEmbed, including when we're the
+// B-side (answered a ring). Logic is symmetric: "leave if peer left or never
+// arrived". Rename pending — see dm_calls_techdebt.md §5.15.
+//
+// Covers four cases (all §5.5):
+// 1. Peer declines — RTCDecline timeline event → hangup immediately.
+// 2. Peer never joins — no-answer timer fires (lifetime + grace).
+// 3. Peer joins then leaves — memberships go empty → hangup after grace.
+// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
+//
+// KNOWN GAPS (also in dm_calls_techdebt.md):
+// §5.9 Does NOT handle encrypted DMs. `ev.getType()` returns
+// 'm.room.encrypted' on first fire; we early-exit. Needs
+// `MatrixEventEvent.Decrypted` listener + `decryptEventIfNeeded`,
+// pattern authoritative in CallEmbed.ts:233-234.
+// §5.16 Decline is not tied to our specific ring event id. Any RTCDecline
+// from peer in this room kills our call.
+// §5.18 Grace constants are empirically chosen, may need tuning with
+// real-world /sync + LiveKit reconnect metrics.
+
+import { useEffect } from 'react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import { EventType, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
+import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
+import { useMatrixClient } from './useMatrixClient';
+import { callEmbedAtom } from '../state/callEmbed';
+import { mDirectAtom } from '../state/mDirectList';
+import { RTC_NOTIFICATION_DEFAULT_LIFETIME } from '../utils/rtcNotification';
+
+// Grace beyond the ring lifetime before giving up on no-answer. Covers /sync
+// latency between B joining and A seeing the membership state event.
+const NO_ANSWER_GRACE_MS = 10_000;
+
+// Wait after the peer's last membership drops before tearing down. Matrix RTC
+// memberships can flap on LiveKit reconnect / short network hiccups; without a
+// grace we'd kill a live call on every blip.
+const PEER_LEAVE_GRACE_MS = 8_000;
+
+export const useCallerAutoHangup = (): void => {
+ const mx = useMatrixClient();
+ const callEmbed = useAtomValue(callEmbedAtom);
+ const setCallEmbed = useSetAtom(callEmbedAtom);
+ const mDirect = useAtomValue(mDirectAtom);
+
+ useEffect(() => {
+ if (!callEmbed) return undefined;
+ const roomId = callEmbed.roomId;
+ if (!mDirect.has(roomId)) return undefined;
+
+ const selfId = mx.getUserId();
+ if (!selfId) return undefined;
+ const selfDeviceId = mx.getDeviceId();
+
+ const isSelf = (m: CallMembership): boolean =>
+ m.sender === selfId && (!selfDeviceId || m.deviceId === selfDeviceId);
+
+ const session = mx.matrixRTC.getRoomSession(callEmbed.room);
+
+ let disposed = false;
+ let peerSeen = session.memberships.some((m) => !isSelf(m));
+ let peerLeaveTimer: ReturnType | undefined;
+
+ const performHangup = () => {
+ if (disposed) return;
+ disposed = true;
+ if (callEmbed.joined) {
+ callEmbed.hangup().catch(() => setCallEmbed(undefined));
+ } else {
+ setCallEmbed(undefined);
+ }
+ };
+
+ const clearPeerLeaveTimer = () => {
+ if (peerLeaveTimer) {
+ clearTimeout(peerLeaveTimer);
+ peerLeaveTimer = undefined;
+ }
+ };
+
+ const noAnswerTimer: ReturnType | undefined = peerSeen
+ ? undefined
+ : setTimeout(performHangup, RTC_NOTIFICATION_DEFAULT_LIFETIME + NO_ANSWER_GRACE_MS);
+
+ const onMemberships = (_prev: CallMembership[], next: CallMembership[]) => {
+ const peerPresent = next.some((m) => !isSelf(m));
+ if (peerPresent) {
+ clearPeerLeaveTimer();
+ if (!peerSeen) {
+ peerSeen = true;
+ if (noAnswerTimer) clearTimeout(noAnswerTimer);
+ }
+ return;
+ }
+ if (peerSeen && !peerLeaveTimer) {
+ peerLeaveTimer = setTimeout(performHangup, PEER_LEAVE_GRACE_MS);
+ }
+ };
+
+ const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, room, _s, _r, data) => {
+ if (!data.liveEvent || !room) return;
+ if (room.roomId !== roomId) return;
+ if (ev.getType() !== EventType.RTCDecline) return;
+ if (ev.getSender() === selfId) return;
+ performHangup();
+ };
+
+ session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
+ mx.on(RoomEvent.Timeline, onTimeline);
+
+ return () => {
+ disposed = true;
+ if (noAnswerTimer) clearTimeout(noAnswerTimer);
+ clearPeerLeaveTimer();
+ session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
+ mx.removeListener(RoomEvent.Timeline, onTimeline);
+ };
+ }, [mx, callEmbed, mDirect, setCallEmbed]);
+};
diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts
index bfdabfb5..0562c0d9 100644
--- a/src/app/hooks/useIncomingRtcNotifications.ts
+++ b/src/app/hooks/useIncomingRtcNotifications.ts
@@ -1,3 +1,19 @@
+// Incoming DM ring: watches `m.rtc.notification` in the live timeline and
+// populates `incomingCallsAtom` so the bottom strip can render.
+//
+// KNOWN GAP §5.9 (blocks Phase 2 commit): encrypted DMs.
+// `RoomEvent.Timeline` fires once with `ev.getType() === 'm.room.encrypted'`
+// (verified in matrix-js-sdk 38.2 event-timeline-set.js:563, no re-emit post
+// decrypt). Our `!== EventType.RTCNotification` filter early-exits and the
+// ring never reaches the strip. Authoritative fix pattern: also
+// `mx.on(MatrixEventEvent.Decrypted, h)` and `mx.decryptEventIfNeeded(ev)` —
+// see CallEmbed.ts:233-234 for the reference implementation.
+//
+// The `registryRef` sync-effect below handles an asymmetry §5.6: REMOVE on the
+// atom can come from outside the hook (strip buttons), and we need to drop
+// timers/listeners for those keys to avoid leaks and to let a fresh ring with
+// the same dedup key re-trigger.
+
import { useEffect, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import {
@@ -108,6 +124,7 @@ export const useIncomingRtcNotifications = (): void => {
const mx = useMatrixClient();
const mDirect = useAtomValue(mDirectAtom);
const callEmbed = useAtomValue(callEmbedAtom);
+ const incoming = useAtomValue(incomingCallsAtom);
const setIncoming = useSetAtom(incomingCallsAtom);
const mDirectRef = useRef(mDirect);
@@ -115,6 +132,8 @@ export const useIncomingRtcNotifications = (): void => {
const inCallRef = useRef(callEmbed !== undefined);
inCallRef.current = callEmbed !== undefined;
+ const registryRef = useRef