321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
// Incoming DM ring: watches `m.rtc.notification` in the live timeline and
|
|
// populates `incomingCallsAtom` so the bottom strip can render.
|
|
//
|
|
// Encrypted DMs (§5.9): `RoomEvent.Timeline` fires once with
|
|
// `ev.getType() === 'm.room.encrypted'` and is never re-emitted after decrypt
|
|
// (matrix-js-sdk 38.2 event-timeline-set.js:563). We listen to both Timeline
|
|
// and `MatrixEventEvent.Decrypted` and kick decryption from the Timeline
|
|
// handler — mirrors CallEmbed.ts:233-234. Dedup relies on `registry.has(key)`
|
|
// for RTCNotification and on idempotent `removeByNotifId` for RTCDecline, so
|
|
// double delivery (Timeline for cleartext + Decrypted for encrypted) is safe.
|
|
//
|
|
// 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 { useAtomValue, useSetAtom } from 'jotai';
|
|
import {
|
|
EventType,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
MatrixEventEvent,
|
|
MatrixEventHandlerMap,
|
|
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 incoming = useAtomValue(incomingCallsAtom);
|
|
const setIncoming = useSetAtom(incomingCallsAtom);
|
|
|
|
const mDirectRef = useRef(mDirect);
|
|
mDirectRef.current = mDirect;
|
|
const inCallRef = useRef(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.
|
|
useEffect(() => {
|
|
if (callEmbed) {
|
|
setIncoming({ type: 'REMOVE_BY_ROOM', roomId: callEmbed.roomId });
|
|
}
|
|
}, [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(() => {
|
|
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 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 processEvent = async (ev: MatrixEvent, room: Room): Promise<void> => {
|
|
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 handleTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (
|
|
ev,
|
|
room,
|
|
_toStartOfTimeline,
|
|
_removed,
|
|
data
|
|
) => {
|
|
if (!data.liveEvent || !room) return;
|
|
// Encrypted events land here as m.room.encrypted; kick decryption and let
|
|
// the Decrypted handler pick them up once the cleartext is available.
|
|
if (ev.isEncrypted()) {
|
|
mx.decryptEventIfNeeded(ev).catch(() => {});
|
|
return;
|
|
}
|
|
processEvent(ev, room);
|
|
};
|
|
|
|
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => {
|
|
if (err) return;
|
|
const roomId = ev.getRoomId();
|
|
if (!roomId) return;
|
|
const room = mx.getRoom(roomId);
|
|
if (!room) return;
|
|
// No liveEvent flag on Decrypted. Backfill safety relies on
|
|
// `isRtcNotificationExpired` (ring branch) and `registry.has(key)` dedup;
|
|
// a stale decline just no-ops via `removeByNotifId`.
|
|
processEvent(ev, room);
|
|
};
|
|
|
|
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(MatrixEventEvent.Decrypted, handleDecrypted);
|
|
mx.on(RoomEvent.Redaction, handleRedaction);
|
|
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
|
|
|
|
return () => {
|
|
mx.removeListener(RoomEvent.Timeline, handleTimeline);
|
|
mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
|
|
mx.removeListener(RoomEvent.Redaction, handleRedaction);
|
|
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
|
|
registry.forEach((entry) => {
|
|
clearTimeout(entry.timer);
|
|
entry.unsubMemberships?.();
|
|
});
|
|
registry.clear();
|
|
};
|
|
}, [mx, setIncoming]);
|
|
};
|