vojo/src/app/hooks/useIncomingRtcNotifications.ts

523 lines
21 KiB
TypeScript

// Incoming DM ring: watches `m.rtc.notification` in the live timeline and
// populates `incomingCallsAtom` so the bottom strip can render.
//
// Encrypted DMs: `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: 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';
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';
import { callForegroundService } from '../plugins/call/callForegroundService';
// Extract the room-scoped call slot id from a raw membership state event,
// covering both the legacy `m.call.member` shape (SessionMembershipData with
// `call_id` string) and the `m.rtc.member` shape (RtcMembershipData with
// `slot_id` of form `'{application}#{id}'`). Returns the bare id portion;
// caller normalizes the legacy `''` ↔ `'ROOM'` equivalence at the dedup-key
// boundary (see `getIncomingCallKey`). Returns undefined for malformed events.
const extractCallIdFromMembershipEvent = (ev: MatrixEvent): string | undefined => {
if (ev.getType() === EventType.RTCMembership) {
const slotId = (ev.getContent() as { slot_id?: unknown }).slot_id;
if (typeof slotId !== 'string') return undefined;
const hashIdx = slotId.indexOf('#');
return hashIdx >= 0 ? slotId.slice(hashIdx + 1) : '';
}
return ev.getContent<SessionMembershipData>().call_id ?? '';
};
// Returns the room-scoped slot id ('' for legacy or new with implicit default,
// 'ROOM' after SDK 40.2 compat-hack via senderMembership.slotDescription.id;
// dedup-key normalization handled in getIncomingCallKey). Returns undefined
// when no membership is known. matrix-js-sdk 39+ exposes the slot id as
// `slotDescription.id` parsed from new `slot_id` or computed from legacy
// `call_id`; the runtime `CallMembership.callId` getter was removed.
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.userId === sender);
if (senderMembership) {
return senderMembership.slotDescription.id;
}
// Race-recovery: ring arrived before /sync delivered the membership state
// event into the SDK session. Fall back to direct state lookup, covering
// both legacy `m.call.member` and `m.rtc.member` event types so a peer on
// an MSC4354-capable homeserver doesn't silently produce an empty key here.
const stateEvents = [
...room.currentState.getStateEvents(EventType.GroupCallMemberPrefix),
...room.currentState.getStateEvents(EventType.RTCMembership),
];
const senderStateEv = stateEvents.find((ev) => ev.getSender() === sender);
if (senderStateEv) {
return extractCallIdFromMembershipEvent(senderStateEv);
}
const timelineEv = room.findEventById(membershipEventId);
if (timelineEv) {
return extractCallIdFromMembershipEvent(timelineEv);
}
const stateEv = stateEvents.find((ev) => ev.getId() === membershipEventId);
if (stateEv) {
return extractCallIdFromMembershipEvent(stateEv);
}
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) => {
let timeout: ReturnType<typeof setTimeout>;
const handler = () => {
const found = findCallIdSync(mx, room, sender, membershipEventId);
if (found !== undefined) {
clearTimeout(timeout);
session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
resolve(found);
}
};
timeout = setTimeout(() => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
resolve(undefined);
}, 5000);
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 extractCallIdFromMembershipEvent(fetched);
} catch {
return undefined;
}
};
type RegistryEntry = {
roomId: string;
notifEventId: string;
// Sender-reported origin timestamp (ms since epoch). Used for
// latest-wins-by-senderTs on same-room second rings: a late-arriving older
// ring must not evict a newer one already seated. See processEvent below.
senderTs: number;
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 registryRef = useRef<Map<string, RegistryEntry>>(new Map());
// Notification IDs whose RTCDecline already arrived — stops a late-decrypted
// RTCNotification from resurrecting an already-declined ring when decline is
// cleartext (killed-state receiver path) but notification is encrypted.
const declinedTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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 hook-local 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.
//
// Also catches the external-remove paths for the Android native ring
// registry: removals issued by strip buttons, pendingCallActionConsumer,
// and the callEmbed join effect go through setIncoming directly without
// touching the hook-local registry — this effect is the single place we can
// call removeIncomingRing on the bridge for those paths. Internal removals
// (removeByKey) call removeIncomingRing inline so they don't rely on this
// effect seeing the registry entry.
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?.();
callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
/* best-effort — registry tombstones the eventId regardless */
});
registry.delete(key);
}
});
}, [incoming]);
useEffect(() => {
const registry = registryRef.current;
const declinedTimers = declinedTimersRef.current;
const removeByKey = (key: string) => {
const entry = registry.get(key);
if (!entry) return;
clearTimeout(entry.timer);
entry.unsubMemberships?.();
// Bottom of the internal-removal stack (removeByRoom / removeByNotifId
// funnel through here). Bridge the native ring registry remove before
// clearing the hook-local registry: the [incoming] sync-effect above
// only sees entries still present at atom-change time, so if we
// deleted here and then setIncoming, the effect would miss this removal
// and the native registry would keep the entry. External paths (strip
// buttons, pendingCallActionConsumer) keep registry intact across the
// atom change and are handled by the sync-effect.
callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
/* best-effort — registry tombstones the eventId regardless */
});
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.userId === 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 rememberDeclined = (notifEventId: string) => {
const existing = declinedTimers.get(notifEventId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
declinedTimers.delete(notifEventId);
}, RTC_NOTIFICATION_DEFAULT_LIFETIME);
declinedTimers.set(notifEventId, timer);
};
const processEvent = async (ev: MatrixEvent, room: Room): Promise<void> => {
if (ev.getType() === EventType.RTCDecline) {
const rel = ev.getRelation();
if (rel?.event_id) {
rememberDeclined(rel.event_id);
removeByNotifId(rel.event_id);
// Explicit forget for the decline-first race: if the notification
// processEvent hasn't run yet (or hasn't reached ADD because
// resolveCallId is mid-await), registry doesn't own the notifEventId
// and removeByNotifId is a no-op. The Java registry may still hold
// the FCM seed — a render on next backgrounding would resurrect
// an already-declined ring.
callForegroundService.removeIncomingRing(rel.event_id).catch(() => {
/* best-effort */
});
}
return;
}
if (ev.getType() !== EventType.RTCNotification) return;
if (ev.getSender() === mx.getSafeUserId()) return;
const evId = ev.getId();
// The Java-side ring registry may already hold an entry for this
// eventId from an FCM seed that landed before /sync delivered the event
// to us. Every suppress-return below must remove+tombstone the entry so
// the next MainActivity.onPause renderRegistry doesn't surface native
// CallStyle for a ring JS decided not to ring. Paths we don't explicitly
// remove here (missing evId / malformed rel): registry lifetime check
// in isExpired remains the only safety net.
const removeFromRegistry = () => {
if (evId) {
callForegroundService.removeIncomingRing(evId).catch(() => {
/* best-effort */
});
}
};
const content = ev.getContent<IRTCNotificationContent>();
// Only DM ring — group call notifications use 'notification' type and are out of scope.
// FCM path only seeds registry for 'ring' content_notification_type, so no entry to remove.
if (content.notification_type !== 'ring') return;
if (!mDirectRef.current.has(room.roomId)) {
// `mDirectAtom` is hydrated from account-data in a React effect, so it
// can be empty during cold/warm startup even for a legitimate DM. Keep
// the native registry alive here: pre-registry behavior was "no JS
// strip", not "cancel the Android CallStyle".
return;
}
if (isRtcNotificationExpired(ev)) {
removeFromRegistry();
return;
}
// Already participating in the room session → suppress duplicate toast.
const session = mx.matrixRTC.getRoomSession(room);
if (session.memberships.some((m) => m.userId === mx.getUserId())) {
removeFromRegistry();
return;
}
const rel = ev.getRelation();
if (rel?.rel_type !== RelationType.Reference || !rel.event_id) return;
const sender = ev.getSender();
if (!sender) return;
if (!evId) return;
if (declinedTimers.has(evId)) {
removeFromRegistry();
return;
}
const callId = await resolveCallId(mx, room, sender, rel.event_id);
if (callId === undefined) {
removeFromRegistry();
return;
}
// Re-check anything that can change during the await. resolveCallId can
// yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) —
// a membership join or a matching decline can land meanwhile and must be
// observed before we commit the ADD.
if (session.memberships.some((m) => m.userId === mx.getUserId())) {
removeFromRegistry();
return;
}
if (declinedTimers.has(evId)) {
removeFromRegistry();
return;
}
const key = getIncomingCallKey(callId, room.roomId);
const senderTs = getNotificationEventSendTs(ev);
// Latest-wins per-room, compared by senderTs. A late FCM retry /
// reordered /sync / decrypt lag can deliver an older ring AFTER a
// newer same-room ring already seated in registry. Naive last-observed
// wins would let the stale event evict the newer one. Only evict when
// the incoming event's senderTs is newer than the existing same-room
// entry's; if incoming is older, drop it (return) so the newer ring
// keeps the slot. Native registry enforces the symmetric invariant in
// VojoFirebaseMessagingService.upsertIncomingRing.
const sameRoomStaler = Array.from(registry.values()).filter(
(e) => e.roomId === room.roomId && e.notifEventId !== evId
);
const incomingIsOlder = sameRoomStaler.some(
(e) => e.senderTs > 0 && senderTs > 0 && senderTs < e.senderTs
);
if (incomingIsOlder) {
// Stale same-room event — don't ADD, don't evict. Native side will
// tombstone via its own check when our upsert hits the bridge; here
// we also bridge the removeIncomingRing so any FCM seed for this
// stale eventId goes away.
callForegroundService.removeIncomingRing(evId).catch(() => {
/* best-effort */
});
return;
}
Array.from(registry.entries())
.filter(([, entry]) => entry.roomId === room.roomId && entry.notifEventId !== evId)
.forEach(([supersededKey]) => removeByKey(supersededKey));
// Duplicate processEvent invocation (e.g., timeline + decrypted path for
// the same event). The earlier one owns the ring lifecycle — do NOT
// remove here or we'd tombstone an eventId the first invocation is
// about to ADD.
if (registry.has(key)) return;
const timer = scheduleExpiry(key, ev);
const unsubMemberships = subscribeMemberships(room);
registry.set(key, {
roomId: room.roomId,
notifEventId: evId,
senderTs,
timer,
unsubMemberships,
});
// Happy-path upsert into the native ring registry. Idempotent
// with any prior FCM seed for the same eventId — Java merges metadata
// append-only, so we pass only the fields JS has reliable access to.
const senderMember = room.getMember(sender);
const callerName = senderMember?.rawDisplayName || room.name || sender || 'Vojo';
const lifetime = content.lifetime ?? RTC_NOTIFICATION_DEFAULT_LIFETIME;
callForegroundService
.upsertIncomingRing({
eventId: evId,
roomId: room.roomId,
callerName,
senderTs,
lifetime,
})
.catch(() => {
/* best-effort — FCM seed likely already populated it anyway */
});
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(() => undefined);
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), `registry.has(key)` dedup,
// and `declinedTimersRef` (blocks a late-decrypted notification whose
// decline already landed cleartext-first on the timeline).
processEvent(ev, room);
};
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => {
// v11+ rooms moved `redacts` into content; matrix-js-sdk mirrors the
// older top-level field when possible but the content fallback is
// the safety net.
const redacted =
ev.event.redacts ?? (ev.getContent() as { redacts?: string } | undefined)?.redacts;
if (!redacted) return;
// Symmetric with RTCDecline: if the redaction arrives before
// the notification's processEvent reaches ADD (race via
// handleTimeline / handleDecrypted), registry has no entry →
// removeByNotifId is a no-op, but the Java ring registry may hold
// an FCM seed that would render on next onPause.
// - rememberDeclined blocks any late ADD attempt for this eventId.
// - bridge removeIncomingRing tombstones the native-side entry.
// - removeByNotifId covers the common case where atom already has it.
rememberDeclined(redacted);
removeByNotifId(redacted);
callForegroundService.removeIncomingRing(redacted).catch(() => {
/* best-effort */
});
};
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);
// Sync the Java ring registry on teardown: process-global state
// outlives this hook, so any live entry left unremoved will be rendered
// by the next MainActivity.onPause for a ring no JS owner exists for.
// Reasons to hit this path: logout, client swap, provider remount,
// hot-reload in dev. Best-effort bridge — registry tombstones each
// eventId for the ring lifetime so a late FCM re-delivery can't
// resurrect the zombie.
registry.forEach((entry) => {
clearTimeout(entry.timer);
entry.unsubMemberships?.();
callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => {
/* best-effort */
});
});
registry.clear();
declinedTimers.forEach((timer) => clearTimeout(timer));
declinedTimers.clear();
};
}, [mx, setIncoming]);
};