diff --git a/public/locales/en.json b/public/locales/en.json
index abe1c7f6..e7d37247 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -386,7 +386,11 @@
"start": "Start call",
"join": "Join call",
"unavailable": "Calls are unavailable",
- "busy_other_room": "You are already in a call"
+ "busy_other_room": "You are already in a call",
+ "incoming": "Incoming call…",
+ "answer": "Answer",
+ "decline": "Decline",
+ "unknown_caller": "Unknown caller"
},
"Room": {
"new_messages": "New Messages",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index ad7f1f9a..a8dd9c1a 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -386,7 +386,11 @@
"start": "Позвонить",
"join": "Присоединиться",
"unavailable": "Звонки недоступны",
- "busy_other_room": "Вы уже в другом звонке"
+ "busy_other_room": "Вы уже в другом звонке",
+ "incoming": "Входящий звонок…",
+ "answer": "Ответить",
+ "decline": "Отклонить",
+ "unknown_caller": "Неизвестный абонент"
},
"Room": {
"new_messages": "Новые сообщения",
diff --git a/public/sound/ring.mp3 b/public/sound/ring.mp3
new file mode 100644
index 00000000..386cce69
Binary files /dev/null and b/public/sound/ring.mp3 differ
diff --git a/public/sound/ring.ogg b/public/sound/ring.ogg
new file mode 100644
index 00000000..48c3f1c2
Binary files /dev/null and b/public/sound/ring.ogg differ
diff --git a/src/app/features/call/IncomingCallToast.tsx b/src/app/features/call/IncomingCallToast.tsx
new file mode 100644
index 00000000..41be94f4
--- /dev/null
+++ b/src/app/features/call/IncomingCallToast.tsx
@@ -0,0 +1,178 @@
+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/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts
new file mode 100644
index 00000000..bfdabfb5
--- /dev/null
+++ b/src/app/hooks/useIncomingRtcNotifications.ts
@@ -0,0 +1,260 @@
+import { useEffect, useRef } from 'react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import {
+ EventType,
+ MatrixClient,
+ MatrixEvent,
+ RelationType,
+ Room,
+ RoomEvent,
+ RoomEventHandlerMap,
+} from 'matrix-js-sdk';
+import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types';
+import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
+import {
+ CallMembership,
+ SessionMembershipData,
+} from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
+import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
+import { useMatrixClient } from './useMatrixClient';
+import { mDirectAtom } from '../state/mDirectList';
+import { callEmbedAtom } from '../state/callEmbed';
+import { incomingCallsAtom } from '../state/incomingCalls';
+import {
+ getIncomingCallKey,
+ getNotificationEventSendTs,
+ isRtcNotificationExpired,
+ RTC_NOTIFICATION_DEFAULT_LIFETIME,
+} from '../utils/rtcNotification';
+
+// Returns "" for room-scoped calls (MSC4143/MSC3401v2 — empty call_id means
+// "the only call in this room"). Returns undefined when no membership is known.
+const findCallIdSync = (
+ mx: MatrixClient,
+ room: Room,
+ sender: string,
+ membershipEventId: string
+): string | undefined => {
+ const session = mx.matrixRTC.getRoomSession(room);
+ const senderMembership = session.memberships.find((m) => m.sender === sender);
+ if (senderMembership && typeof senderMembership.callId === 'string') {
+ return senderMembership.callId;
+ }
+
+ const stateEvents = room.currentState.getStateEvents(EventType.GroupCallMemberPrefix);
+ const senderStateEv = stateEvents.find((ev) => ev.getSender() === sender);
+ if (senderStateEv) {
+ return senderStateEv.getContent().call_id ?? '';
+ }
+
+ const timelineEv = room.findEventById(membershipEventId);
+ if (timelineEv) {
+ return timelineEv.getContent().call_id ?? '';
+ }
+
+ const stateEv = stateEvents.find((ev) => ev.getId() === membershipEventId);
+ if (stateEv) {
+ return stateEv.getContent().call_id ?? '';
+ }
+
+ return undefined;
+};
+
+const resolveCallId = async (
+ mx: MatrixClient,
+ room: Room,
+ sender: string,
+ membershipEventId: string
+): Promise => {
+ const sync = findCallIdSync(mx, room, sender, membershipEventId);
+ if (sync !== undefined) return sync;
+
+ // Race: ring arrived before /sync delivered the membership state event.
+ const session = mx.matrixRTC.getRoomSession(room);
+ const waited = await new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
+ resolve(undefined);
+ }, 5000);
+ const handler = () => {
+ const found = findCallIdSync(mx, room, sender, membershipEventId);
+ if (found !== undefined) {
+ clearTimeout(timeout);
+ session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
+ resolve(found);
+ }
+ };
+ session.on(MatrixRTCSessionEvent.MembershipsChanged, handler);
+ });
+ if (waited !== undefined) return waited;
+
+ try {
+ const raw = await mx.fetchRoomEvent(room.roomId, membershipEventId);
+ const fetched = new MatrixEvent(raw);
+ return fetched.getContent().call_id ?? '';
+ } catch {
+ return undefined;
+ }
+};
+
+type RegistryEntry = {
+ roomId: string;
+ notifEventId: string;
+ timer: ReturnType;
+ unsubMemberships?: () => void;
+};
+
+export const useIncomingRtcNotifications = (): void => {
+ const mx = useMatrixClient();
+ const mDirect = useAtomValue(mDirectAtom);
+ const callEmbed = useAtomValue(callEmbedAtom);
+ const setIncoming = useSetAtom(incomingCallsAtom);
+
+ const mDirectRef = useRef(mDirect);
+ mDirectRef.current = mDirect;
+ const inCallRef = useRef(callEmbed !== undefined);
+ inCallRef.current = callEmbed !== undefined;
+
+ // When local user joins any call (via header / other UI), drop any toast for that room.
+ useEffect(() => {
+ if (callEmbed) {
+ setIncoming({ type: 'REMOVE_BY_ROOM', roomId: callEmbed.roomId });
+ }
+ }, [callEmbed, setIncoming]);
+
+ useEffect(() => {
+ const registry = new Map();
+
+ const removeByKey = (key: string) => {
+ const entry = registry.get(key);
+ if (!entry) return;
+ clearTimeout(entry.timer);
+ entry.unsubMemberships?.();
+ registry.delete(key);
+ setIncoming({ type: 'REMOVE', key });
+ };
+
+ const removeByRoom = (roomId: string) => {
+ Array.from(registry.entries())
+ .filter(([, entry]) => entry.roomId === roomId)
+ .forEach(([key]) => removeByKey(key));
+ };
+
+ const removeByNotifId = (notifEventId: string) => {
+ Array.from(registry.entries())
+ .filter(([, entry]) => entry.notifEventId === notifEventId)
+ .forEach(([key]) => removeByKey(key));
+ };
+
+ const subscribeMemberships = (room: Room): (() => void) => {
+ const session = mx.matrixRTC.getRoomSession(room);
+ const handler = (
+ _old: CallMembership[],
+ next: CallMembership[]
+ ) => {
+ if (next.some((m) => m.sender === mx.getUserId())) {
+ removeByRoom(room.roomId);
+ }
+ };
+ session.on(MatrixRTCSessionEvent.MembershipsChanged, handler);
+ return () => session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
+ };
+
+ const scheduleExpiry = (key: string, ev: MatrixEvent): ReturnType => {
+ const content = ev.getContent();
+ const lifetime = content.lifetime ?? RTC_NOTIFICATION_DEFAULT_LIFETIME;
+ const expireAt = getNotificationEventSendTs(ev) + lifetime;
+ const delay = Math.max(0, expireAt - Date.now());
+ return setTimeout(() => removeByKey(key), delay);
+ };
+
+ const handleTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = async (
+ ev,
+ room,
+ _toStartOfTimeline,
+ _removed,
+ data
+ ) => {
+ if (!data.liveEvent || !room) return;
+
+ if (ev.getType() === EventType.RTCDecline) {
+ const rel = ev.getRelation();
+ if (rel?.event_id) removeByNotifId(rel.event_id);
+ return;
+ }
+
+ if (ev.getType() !== EventType.RTCNotification) return;
+ if (ev.getSender() === mx.getSafeUserId()) return;
+
+ const content = ev.getContent();
+ // Only DM ring — group call notifications use 'notification' type and are out of scope.
+ if (content.notification_type !== 'ring') return;
+ if (!mDirectRef.current.has(room.roomId)) return;
+ if (inCallRef.current) return;
+ if (isRtcNotificationExpired(ev)) return;
+
+ // Already participating in the room session → suppress duplicate toast.
+ const session = mx.matrixRTC.getRoomSession(room);
+ if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
+
+ const rel = ev.getRelation();
+ if (rel?.rel_type !== RelationType.Reference || !rel.event_id) return;
+
+ const sender = ev.getSender();
+ if (!sender) return;
+ const callId = await resolveCallId(mx, room, sender, rel.event_id);
+ if (callId === undefined) return;
+
+ const evId = ev.getId();
+ if (!evId) return;
+
+ // Re-check membership after the (possibly networked) callId resolve —
+ // a join event from another device could have landed during the await.
+ if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
+ if (inCallRef.current) return;
+
+ const key = getIncomingCallKey(callId, room.roomId);
+ if (registry.has(key)) return;
+
+ const timer = scheduleExpiry(key, ev);
+ const unsubMemberships = subscribeMemberships(room);
+
+ registry.set(key, {
+ roomId: room.roomId,
+ notifEventId: evId,
+ timer,
+ unsubMemberships,
+ });
+
+ setIncoming({
+ type: 'ADD',
+ key,
+ call: { notifEvent: ev, roomId: room.roomId, callId },
+ });
+ };
+
+ const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => {
+ const redacted = ev.event.redacts;
+ if (redacted) removeByNotifId(redacted);
+ };
+
+ const handleSessionEnded = (roomId: string) => {
+ removeByRoom(roomId);
+ };
+
+ mx.on(RoomEvent.Timeline, handleTimeline);
+ mx.on(RoomEvent.Redaction, handleRedaction);
+ mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
+
+ return () => {
+ mx.removeListener(RoomEvent.Timeline, handleTimeline);
+ mx.removeListener(RoomEvent.Redaction, handleRedaction);
+ mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
+ registry.forEach((entry) => {
+ clearTimeout(entry.timer);
+ entry.unsubMemberships?.();
+ });
+ registry.clear();
+ };
+ }, [mx, setIncoming]);
+};
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 4de42081..477ca2e9 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -70,6 +70,13 @@ import { SearchModalRenderer } from '../features/search';
import { getFallbackSession } from '../state/sessions';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
+import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
+import { IncomingCallStack } from '../features/call/IncomingCallToast';
+
+function IncomingCallsFeature() {
+ useIncomingRtcNotifications();
+ return ;
+}
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
@@ -137,6 +144,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
diff --git a/src/app/plugins/call/utils.ts b/src/app/plugins/call/utils.ts
index 0ea72b3c..ea656f30 100644
--- a/src/app/plugins/call/utils.ts
+++ b/src/app/plugins/call/utils.ts
@@ -78,6 +78,12 @@ export function getCallCapabilities(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
);
+ capabilities.add(
+ WidgetEventCapability.forRoomEvent(
+ EventDirection.Send,
+ 'org.matrix.msc4075.rtc.notification'
+ ).raw
+ );
capabilities.add(
WidgetEventCapability.forRoomEvent(
EventDirection.Receive,
diff --git a/src/app/state/incomingCalls.ts b/src/app/state/incomingCalls.ts
new file mode 100644
index 00000000..453a499c
--- /dev/null
+++ b/src/app/state/incomingCalls.ts
@@ -0,0 +1,60 @@
+import { atom } from 'jotai';
+import { MatrixEvent } from 'matrix-js-sdk';
+
+export type IncomingCall = {
+ notifEvent: MatrixEvent;
+ roomId: string;
+ callId: string;
+};
+
+export type IncomingCallAction =
+ | { type: 'ADD'; key: string; call: IncomingCall }
+ | { type: 'REMOVE'; key: string }
+ | { type: 'REMOVE_BY_ROOM'; roomId: string }
+ | { type: 'REMOVE_BY_NOTIF_ID'; notifEventId: string };
+
+const baseIncomingCallsAtom = atom