From f862f19f09d0198b8aff6efef7545954064d8585 Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 19 Apr 2026 10:11:09 +0300 Subject: [PATCH] dm calls mvp: phase 2: mvp of accept/decline calls (contain bugs) --- public/locales/en.json | 6 +- public/locales/ru.json | 6 +- public/sound/ring.mp3 | Bin 0 -> 15273 bytes public/sound/ring.ogg | Bin 0 -> 18586 bytes src/app/features/call/IncomingCallToast.tsx | 178 +++++++++++++ src/app/hooks/useIncomingRtcNotifications.ts | 260 +++++++++++++++++++ src/app/pages/Router.tsx | 8 + src/app/plugins/call/utils.ts | 6 + src/app/state/incomingCalls.ts | 60 +++++ src/app/utils/rtcNotification.ts | 21 ++ 10 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 public/sound/ring.mp3 create mode 100644 public/sound/ring.ogg create mode 100644 src/app/features/call/IncomingCallToast.tsx create mode 100644 src/app/hooks/useIncomingRtcNotifications.ts create mode 100644 src/app/state/incomingCalls.ts create mode 100644 src/app/utils/rtcNotification.ts 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 0000000000000000000000000000000000000000..386cce6998587011b9303ca409a527577c53a1f4 GIT binary patch literal 15273 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 literal 0 HcmV?d00001 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')} + + + + + + + + ); +} + +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>(new Map()); + +export const incomingCallsAtom = atom, [IncomingCallAction], void>( + (get) => get(baseIncomingCallsAtom), + (get, set, action) => { + const current = get(baseIncomingCallsAtom); + if (action.type === 'ADD') { + if (current.has(action.key)) return; + const next = new Map(current); + next.set(action.key, action.call); + set(baseIncomingCallsAtom, next); + return; + } + if (action.type === 'REMOVE') { + if (!current.has(action.key)) return; + const next = new Map(current); + next.delete(action.key); + set(baseIncomingCallsAtom, next); + return; + } + if (action.type === 'REMOVE_BY_ROOM') { + const next = new Map(current); + let changed = false; + next.forEach((call, key) => { + if (call.roomId === action.roomId) { + next.delete(key); + changed = true; + } + }); + if (changed) set(baseIncomingCallsAtom, next); + return; + } + if (action.type === 'REMOVE_BY_NOTIF_ID') { + const next = new Map(current); + let changed = false; + next.forEach((call, key) => { + if (call.notifEvent.getId() === action.notifEventId) { + next.delete(key); + changed = true; + } + }); + if (changed) set(baseIncomingCallsAtom, next); + } + } +); diff --git a/src/app/utils/rtcNotification.ts b/src/app/utils/rtcNotification.ts new file mode 100644 index 00000000..1ffac03a --- /dev/null +++ b/src/app/utils/rtcNotification.ts @@ -0,0 +1,21 @@ +import { MatrixEvent } from 'matrix-js-sdk'; +import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types'; + +export const RTC_NOTIFICATION_DEFAULT_LIFETIME = 30_000; + +export const getNotificationEventSendTs = (ev: MatrixEvent): number => { + const content = ev.getContent(); + const sendTs = content.sender_ts; + const eventTs = ev.getTs(); + if (sendTs && Math.abs(sendTs - eventTs) >= 15_000) return eventTs; + return sendTs ?? eventTs; +}; + +export const isRtcNotificationExpired = (ev: MatrixEvent, now = Date.now()): boolean => { + const content = ev.getContent(); + const lifetime = content.lifetime ?? RTC_NOTIFICATION_DEFAULT_LIFETIME; + return now - getNotificationEventSendTs(ev) > lifetime; +}; + +export const getIncomingCallKey = (callId: string, roomId: string): string => + `call_${callId}_${roomId}`;