dm calls mvp: phase 2: mvp of accept/decline calls (contain bugs)
This commit is contained in:
parent
c6d59a3852
commit
0a8a97a050
10 changed files with 543 additions and 2 deletions
|
|
@ -386,7 +386,11 @@
|
||||||
"start": "Start call",
|
"start": "Start call",
|
||||||
"join": "Join call",
|
"join": "Join call",
|
||||||
"unavailable": "Calls are unavailable",
|
"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": {
|
"Room": {
|
||||||
"new_messages": "New Messages",
|
"new_messages": "New Messages",
|
||||||
|
|
|
||||||
|
|
@ -386,7 +386,11 @@
|
||||||
"start": "Позвонить",
|
"start": "Позвонить",
|
||||||
"join": "Присоединиться",
|
"join": "Присоединиться",
|
||||||
"unavailable": "Звонки недоступны",
|
"unavailable": "Звонки недоступны",
|
||||||
"busy_other_room": "Вы уже в другом звонке"
|
"busy_other_room": "Вы уже в другом звонке",
|
||||||
|
"incoming": "Входящий звонок…",
|
||||||
|
"answer": "Ответить",
|
||||||
|
"decline": "Отклонить",
|
||||||
|
"unknown_caller": "Неизвестный абонент"
|
||||||
},
|
},
|
||||||
"Room": {
|
"Room": {
|
||||||
"new_messages": "Новые сообщения",
|
"new_messages": "Новые сообщения",
|
||||||
|
|
|
||||||
BIN
public/sound/ring.mp3
Normal file
BIN
public/sound/ring.mp3
Normal file
Binary file not shown.
BIN
public/sound/ring.ogg
Normal file
BIN
public/sound/ring.ogg
Normal file
Binary file not shown.
178
src/app/features/call/IncomingCallToast.tsx
Normal file
178
src/app/features/call/IncomingCallToast.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Box
|
||||||
|
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||||
|
direction="Row"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: config.shadow.E300,
|
||||||
|
minWidth: toRem(280),
|
||||||
|
maxWidth: toRem(360),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar size="300">
|
||||||
|
<UserAvatar
|
||||||
|
userId={senderId ?? ''}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={displayName}
|
||||||
|
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{t('Call.incoming')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Solid"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleDecline}
|
||||||
|
before={<Icon src={Icons.PhoneDown} size="100" filled />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{t('Call.decline')}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Success"
|
||||||
|
fill="Solid"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleAnswer}
|
||||||
|
before={<Icon src={Icons.Phone} size="100" filled />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{t('Call.answer')}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IncomingCallStack() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const incoming = useAtomValue(incomingCallsAtom);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(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 */}
|
||||||
|
<audio ref={audioRef} loop preload="auto" style={{ display: 'none' }}>
|
||||||
|
<source src={RingSoundOgg} type="audio/ogg" />
|
||||||
|
<source src={RingSoundMp3} type="audio/mpeg" />
|
||||||
|
</audio>
|
||||||
|
{hasIncoming && (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: config.space.S400,
|
||||||
|
right: config.space.S400,
|
||||||
|
zIndex: 999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map((call) => {
|
||||||
|
const room = mx.getRoom(call.roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
return (
|
||||||
|
<IncomingCallToast
|
||||||
|
key={`call_${call.callId}_${call.roomId}`}
|
||||||
|
call={call}
|
||||||
|
room={room}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
src/app/hooks/useIncomingRtcNotifications.ts
Normal file
260
src/app/hooks/useIncomingRtcNotifications.ts
Normal file
|
|
@ -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<SessionMembershipData>().call_id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineEv = room.findEventById(membershipEventId);
|
||||||
|
if (timelineEv) {
|
||||||
|
return timelineEv.getContent<SessionMembershipData>().call_id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateEv = stateEvents.find((ev) => ev.getId() === membershipEventId);
|
||||||
|
if (stateEv) {
|
||||||
|
return stateEv.getContent<SessionMembershipData>().call_id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCallId = async (
|
||||||
|
mx: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
sender: string,
|
||||||
|
membershipEventId: string
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
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<string | undefined>((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<SessionMembershipData>().call_id ?? '';
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegistryEntry = {
|
||||||
|
roomId: string;
|
||||||
|
notifEventId: string;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
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<string, RegistryEntry>();
|
||||||
|
|
||||||
|
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<typeof setTimeout> => {
|
||||||
|
const content = ev.getContent<IRTCNotificationContent>();
|
||||||
|
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<IRTCNotificationContent>();
|
||||||
|
// 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]);
|
||||||
|
};
|
||||||
|
|
@ -70,6 +70,13 @@ import { SearchModalRenderer } from '../features/search';
|
||||||
import { getFallbackSession } from '../state/sessions';
|
import { getFallbackSession } from '../state/sessions';
|
||||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||||
|
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
||||||
|
import { IncomingCallStack } from '../features/call/IncomingCallToast';
|
||||||
|
|
||||||
|
function IncomingCallsFeature() {
|
||||||
|
useIncomingRtcNotifications();
|
||||||
|
return <IncomingCallStack />;
|
||||||
|
}
|
||||||
|
|
||||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||||
const { hashRouter } = clientConfig;
|
const { hashRouter } = clientConfig;
|
||||||
|
|
@ -137,6 +144,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
<CallStatusRenderer />
|
<CallStatusRenderer />
|
||||||
|
<IncomingCallsFeature />
|
||||||
</CallEmbedProvider>
|
</CallEmbedProvider>
|
||||||
<SearchModalRenderer />
|
<SearchModalRenderer />
|
||||||
<UserRoomProfileRenderer />
|
<UserRoomProfileRenderer />
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,12 @@ export function getCallCapabilities(
|
||||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
|
||||||
);
|
);
|
||||||
|
|
||||||
|
capabilities.add(
|
||||||
|
WidgetEventCapability.forRoomEvent(
|
||||||
|
EventDirection.Send,
|
||||||
|
'org.matrix.msc4075.rtc.notification'
|
||||||
|
).raw
|
||||||
|
);
|
||||||
capabilities.add(
|
capabilities.add(
|
||||||
WidgetEventCapability.forRoomEvent(
|
WidgetEventCapability.forRoomEvent(
|
||||||
EventDirection.Receive,
|
EventDirection.Receive,
|
||||||
|
|
|
||||||
60
src/app/state/incomingCalls.ts
Normal file
60
src/app/state/incomingCalls.ts
Normal file
|
|
@ -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<Map<string, IncomingCall>>(new Map());
|
||||||
|
|
||||||
|
export const incomingCallsAtom = atom<Map<string, IncomingCall>, [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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
21
src/app/utils/rtcNotification.ts
Normal file
21
src/app/utils/rtcNotification.ts
Normal file
|
|
@ -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<IRTCNotificationContent>();
|
||||||
|
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<IRTCNotificationContent>();
|
||||||
|
const lifetime = content.lifetime ?? RTC_NOTIFICATION_DEFAULT_LIFETIME;
|
||||||
|
return now - getNotificationEventSendTs(ev) > lifetime;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIncomingCallKey = (callId: string, roomId: string): string =>
|
||||||
|
`call_${callId}_${roomId}`;
|
||||||
Loading…
Add table
Reference in a new issue