vojo/src/app/hooks/useIncomingRtcNotifications.ts

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]);
};