dm calls mvp: phase 2: incoming call strip + A-side auto-hangup on decline, peer-leave, no-answer
This commit is contained in:
parent
f862f19f09
commit
79bd0ccc4d
8 changed files with 386 additions and 181 deletions
146
src/app/features/call-status/IncomingCallStrip.tsx
Normal file
146
src/app/features/call-status/IncomingCallStrip.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
||||||
|
shrink="No"
|
||||||
|
gap="400"
|
||||||
|
alignItems="Center"
|
||||||
|
direction="Row"
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="300">
|
||||||
|
<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>
|
||||||
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{t('Call.decline')}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
radii="Pill"
|
||||||
|
size="300"
|
||||||
|
onClick={handleDecline}
|
||||||
|
aria-label={t('Call.decline')}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.PhoneDown} filled />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{t('Call.answer')}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant="Success"
|
||||||
|
fill="Soft"
|
||||||
|
radii="Pill"
|
||||||
|
size="300"
|
||||||
|
onClick={handleAnswer}
|
||||||
|
aria-label={t('Call.answer')}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Phone} filled />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './CallStatus';
|
export * from './CallStatus';
|
||||||
|
export * from './IncomingCallStrip';
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1022,6 +1022,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
[string, MatrixEvent, number, EventTimelineSet, boolean]
|
[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) => {
|
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
||||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||||
|
|
|
||||||
121
src/app/hooks/useCallerAutoHangup.ts
Normal file
121
src/app/hooks/useCallerAutoHangup.ts
Normal file
|
|
@ -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<typeof setTimeout> | 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<typeof setTimeout> | 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]);
|
||||||
|
};
|
||||||
|
|
@ -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 { useEffect, useRef } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
|
|
@ -108,6 +124,7 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirect = useAtomValue(mDirectAtom);
|
const mDirect = useAtomValue(mDirectAtom);
|
||||||
const callEmbed = useAtomValue(callEmbedAtom);
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
const incoming = useAtomValue(incomingCallsAtom);
|
||||||
const setIncoming = useSetAtom(incomingCallsAtom);
|
const setIncoming = useSetAtom(incomingCallsAtom);
|
||||||
|
|
||||||
const mDirectRef = useRef(mDirect);
|
const mDirectRef = useRef(mDirect);
|
||||||
|
|
@ -115,6 +132,8 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
const inCallRef = useRef(callEmbed !== undefined);
|
const inCallRef = useRef(callEmbed !== undefined);
|
||||||
inCallRef.current = callEmbed !== undefined;
|
inCallRef.current = callEmbed !== undefined;
|
||||||
|
|
||||||
|
const registryRef = useRef<Map<string, RegistryEntry>>(new Map());
|
||||||
|
|
||||||
// When local user joins any call (via header / other UI), drop any toast for that room.
|
// When local user joins any call (via header / other UI), drop any toast for that room.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callEmbed) {
|
if (callEmbed) {
|
||||||
|
|
@ -122,8 +141,25 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
}
|
}
|
||||||
}, [callEmbed, setIncoming]);
|
}, [callEmbed, setIncoming]);
|
||||||
|
|
||||||
|
// Any key dropped from the atom (external REMOVE via strip accept/decline, etc.)
|
||||||
|
// must also drop the matching registry entry — otherwise its expiry timer and
|
||||||
|
// memberships listener leak, and a fresh ring for the same dedup key would be
|
||||||
|
// swallowed by `registry.has(key)` until the timer finally fires.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const registry = new Map<string, RegistryEntry>();
|
const registry = registryRef.current;
|
||||||
|
Array.from(registry.keys()).forEach((key) => {
|
||||||
|
if (!incoming.has(key)) {
|
||||||
|
const entry = registry.get(key);
|
||||||
|
if (!entry) return;
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.unsubMemberships?.();
|
||||||
|
registry.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [incoming]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const registry = registryRef.current;
|
||||||
|
|
||||||
const removeByKey = (key: string) => {
|
const removeByKey = (key: string) => {
|
||||||
const entry = registry.get(key);
|
const entry = registry.get(key);
|
||||||
|
|
|
||||||
69
src/app/pages/IncomingCallStripRenderer.tsx
Normal file
69
src/app/pages/IncomingCallStripRenderer.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Top-level renderer for incoming DM call strips + ringtone audio.
|
||||||
|
//
|
||||||
|
// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before
|
||||||
|
// `CallStatusRenderer` so the strip stacks above the in-call pill.
|
||||||
|
//
|
||||||
|
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no
|
||||||
|
// user gesture yet), the ring is silent — strip is still visible but user may
|
||||||
|
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is
|
||||||
|
// Phase 3 polish.
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Box } from 'folds';
|
||||||
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
import { IncomingCallStrip } from '../features/call-status';
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
export function IncomingCallStripRenderer() {
|
||||||
|
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 — strip 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" shrink="No">
|
||||||
|
{entries.map((call) => {
|
||||||
|
const room = mx.getRoom(call.roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
return (
|
||||||
|
<IncomingCallStrip
|
||||||
|
key={`call_${call.callId}_${call.roomId}`}
|
||||||
|
call={call}
|
||||||
|
room={room}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -71,11 +71,13 @@ 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 { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
||||||
import { IncomingCallStack } from '../features/call/IncomingCallToast';
|
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
||||||
|
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
|
||||||
|
|
||||||
function IncomingCallsFeature() {
|
function IncomingCallsFeature() {
|
||||||
useIncomingRtcNotifications();
|
useIncomingRtcNotifications();
|
||||||
return <IncomingCallStack />;
|
useCallerAutoHangup();
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||||
|
|
@ -143,6 +145,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
|
<IncomingCallStripRenderer />
|
||||||
<CallStatusRenderer />
|
<CallStatusRenderer />
|
||||||
<IncomingCallsFeature />
|
<IncomingCallsFeature />
|
||||||
</CallEmbedProvider>
|
</CallEmbedProvider>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue