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')} + + + + + + + + ); +} + +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}`;