501 lines
20 KiB
TypeScript
501 lines
20 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/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';
|
|
import { callForegroundService } from '../plugins/call/callForegroundService';
|
|
|
|
// 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) => {
|
|
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 fetched.getContent<SessionMembershipData>().call_id ?? '';
|
|
} 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.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 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)) {
|
|
removeFromRegistry();
|
|
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.sender === 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.sender === 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]);
|
|
};
|