Enforce DM call switching and foreground native call ownership
This commit is contained in:
parent
5adbe294ef
commit
fab533e762
14 changed files with 381 additions and 143 deletions
|
|
@ -29,13 +29,13 @@ import java.util.Map;
|
||||||
* Message branch: builds a system notification when the activity is NOT in
|
* Message branch: builds a system notification when the activity is NOT in
|
||||||
* the foreground — covering both "backgrounded" and "killed" cases.
|
* the foreground — covering both "backgrounded" and "killed" cases.
|
||||||
*
|
*
|
||||||
* Call branch: for `org.matrix.msc4075.rtc.notification` + `notification_type=ring`
|
* Call branch: when the app is backgrounded we show a CallStyle incoming-call
|
||||||
* we always show a CallStyle incoming-call notification (independent of
|
* notification with Answer/Decline actions + full-screen intent that wakes the
|
||||||
* foreground state) with Answer/Decline actions + full-screen intent that
|
* device and launches MainActivity over the lockscreen. When the app is already
|
||||||
* wakes the device and launches MainActivity over the lockscreen — the
|
* foregrounded, JS owns the UX via the in-app incoming-call strip, so we must
|
||||||
* WhatsApp/Telegram incoming-call UX. The FSI is also what satisfies AOSP
|
* NOT also surface a system banner. The FSI is also what satisfies AOSP
|
||||||
* NotificationManagerService.checkDisqualifyingFeatures on API 31+; without
|
* NotificationManagerService.checkDisqualifyingFeatures on API 31+; without it
|
||||||
* it CallStyle throws IAE and the notification is silently dropped. See
|
* CallStyle throws IAE and the notification is silently dropped. See
|
||||||
* docs/plans/dm_calls.md ADR 2.5-fsi.
|
* docs/plans/dm_calls.md ADR 2.5-fsi.
|
||||||
*/
|
*/
|
||||||
public class VojoFirebaseMessagingService extends MessagingService {
|
public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
|
|
@ -72,7 +72,11 @@ public class VojoFirebaseMessagingService extends MessagingService {
|
||||||
try {
|
try {
|
||||||
if (RTC_NOTIFICATION_TYPE.equals(data.get("type"))
|
if (RTC_NOTIFICATION_TYPE.equals(data.get("type"))
|
||||||
&& "ring".equals(data.get("content_notification_type"))) {
|
&& "ring".equals(data.get("content_notification_type"))) {
|
||||||
Log.d(TAG, "route: call-branch");
|
if (MainActivity.isInForeground) {
|
||||||
|
Log.d(TAG, "route: skip call notif (foreground, JS strip owns UX)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.d(TAG, "route: call-branch (background)");
|
||||||
showIncomingCallNotification(remoteMessage);
|
showIncomingCallNotification(remoteMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { ReactNode, useCallback, useRef } from 'react';
|
import React, { ReactNode, useCallback, useRef } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
import {
|
import {
|
||||||
CallEmbedContextProvider,
|
CallEmbedContextProvider,
|
||||||
CallEmbedRefContextProvider,
|
CallEmbedRefContextProvider,
|
||||||
|
|
@ -15,14 +15,16 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
|
|
||||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
useCallMemberSoundSync(embed);
|
useCallMemberSoundSync(embed);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
embed,
|
embed,
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
if (store.get(callEmbedAtom) !== embed) return;
|
||||||
setCallEmbed(undefined);
|
setCallEmbed(undefined);
|
||||||
}, [setCallEmbed])
|
}, [store, embed, setCallEmbed])
|
||||||
);
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useDmCallStart } from '../../hooks/useDmCallStart';
|
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
||||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||||
import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
|
import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
|
||||||
import { getIncomingCallKey } from '../../utils/rtcNotification';
|
import { getIncomingCallKey } from '../../utils/rtcNotification';
|
||||||
|
|
@ -28,7 +28,7 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const startDmCall = useDmCallStart();
|
const switchOrStartDmCall = useSwitchOrStartDmCall();
|
||||||
const setIncoming = useSetAtom(incomingCallsAtom);
|
const setIncoming = useSetAtom(incomingCallsAtom);
|
||||||
|
|
||||||
const senderId = call.notifEvent.getSender();
|
const senderId = call.notifEvent.getSender();
|
||||||
|
|
@ -45,9 +45,20 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
||||||
const callKey = getIncomingCallKey(call.callId, call.roomId);
|
const callKey = getIncomingCallKey(call.callId, call.roomId);
|
||||||
|
|
||||||
const handleAnswer = () => {
|
const handleAnswer = () => {
|
||||||
setIncoming({ type: 'REMOVE', key: callKey });
|
|
||||||
startDmCall(call.roomId);
|
|
||||||
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)));
|
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)));
|
||||||
|
switchOrStartDmCall(call.roomId)
|
||||||
|
.then(() => {
|
||||||
|
const evId = call.notifEvent.getId();
|
||||||
|
if (evId) {
|
||||||
|
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: evId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIncoming({ type: 'REMOVE', key: callKey });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[call] strip answer switch/start failed', err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDecline = async () => {
|
const handleDecline = async () => {
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
import { useDmCallStart } from '../../hooks/useDmCallStart';
|
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
|
|
@ -263,7 +263,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
||||||
function DmCallButton({ room }: { room: Room }) {
|
function DmCallButton({ room }: { room: Room }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const startDmCall = useDmCallStart();
|
const switchOrStartDmCall = useSwitchOrStartDmCall();
|
||||||
const livekitSupported = useLivekitSupport();
|
const livekitSupported = useLivekitSupport();
|
||||||
const session = useCallSession(room);
|
const session = useCallSession(room);
|
||||||
const members = useCallMembers(room, session);
|
const members = useCallMembers(room, session);
|
||||||
|
|
@ -273,19 +273,20 @@ function DmCallButton({ room }: { room: Room }) {
|
||||||
const inCallHere = currentEmbed?.roomId === room.roomId;
|
const inCallHere = currentEmbed?.roomId === room.roomId;
|
||||||
if (inCallHere) return null;
|
if (inCallHere) return null;
|
||||||
|
|
||||||
const inCallElsewhere = !!currentEmbed && currentEmbed.roomId !== room.roomId;
|
|
||||||
const ongoingByOthers = members.length > 0 && !members.some((m) => m.sender === myUserId);
|
const ongoingByOthers = members.length > 0 && !members.some((m) => m.sender === myUserId);
|
||||||
|
|
||||||
const disabled = !livekitSupported || inCallElsewhere;
|
const disabled = !livekitSupported;
|
||||||
let tooltipText: string;
|
let tooltipText: string;
|
||||||
if (!livekitSupported) tooltipText = t('Call.unavailable');
|
if (!livekitSupported) tooltipText = t('Call.unavailable');
|
||||||
else if (inCallElsewhere) tooltipText = t('Call.busy_other_room');
|
|
||||||
else if (ongoingByOthers) tooltipText = t('Call.join');
|
else if (ongoingByOthers) tooltipText = t('Call.join');
|
||||||
else tooltipText = t('Call.start');
|
else tooltipText = t('Call.start');
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
startDmCall(room.roomId);
|
switchOrStartDmCall(room.roomId).catch((err: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[call] header switch/start failed', err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@
|
||||||
// 3. Peer joins then leaves — memberships go empty → hangup after grace.
|
// 3. Peer joins then leaves — memberships go empty → hangup after grace.
|
||||||
// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
|
// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
|
||||||
//
|
//
|
||||||
// KNOWN GAPS (also in dm_calls_techdebt.md):
|
// KNOWN GAP (also in dm_calls_techdebt.md):
|
||||||
// §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
|
// §5.18 Grace constants are empirically chosen, may need tuning with
|
||||||
// real-world /sync + LiveKit reconnect metrics.
|
// real-world /sync + LiveKit reconnect metrics.
|
||||||
//
|
//
|
||||||
|
|
@ -34,6 +32,7 @@ import {
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||||
|
import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { callEmbedAtom } from '../state/callEmbed';
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
|
|
@ -48,6 +47,21 @@ const NO_ANSWER_GRACE_MS = 10_000;
|
||||||
// grace we'd kill a live call on every blip.
|
// grace we'd kill a live call on every blip.
|
||||||
const PEER_LEAVE_GRACE_MS = 8_000;
|
const PEER_LEAVE_GRACE_MS = 8_000;
|
||||||
|
|
||||||
|
const findLatestOwnRingNotifEvent = (
|
||||||
|
selfId: string,
|
||||||
|
events: MatrixEvent[]
|
||||||
|
): MatrixEvent | undefined => {
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i];
|
||||||
|
if (ev.getSender() === selfId && ev.getType() === EventType.RTCNotification) {
|
||||||
|
const content = ev.getContent<IRTCNotificationContent>();
|
||||||
|
if (content.notification_type === 'ring') return ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const useCallerAutoHangup = (): void => {
|
export const useCallerAutoHangup = (): void => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const callEmbed = useAtomValue(callEmbedAtom);
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
|
@ -56,7 +70,7 @@ export const useCallerAutoHangup = (): void => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callEmbed) return undefined;
|
if (!callEmbed) return undefined;
|
||||||
const roomId = callEmbed.roomId;
|
const { roomId } = callEmbed;
|
||||||
if (!mDirect.has(roomId)) return undefined;
|
if (!mDirect.has(roomId)) return undefined;
|
||||||
|
|
||||||
const selfId = mx.getUserId();
|
const selfId = mx.getUserId();
|
||||||
|
|
@ -71,6 +85,11 @@ export const useCallerAutoHangup = (): void => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let peerSeen = session.memberships.some((m) => !isSelf(m));
|
let peerSeen = session.memberships.some((m) => !isSelf(m));
|
||||||
let peerLeaveTimer: ReturnType<typeof setTimeout> | undefined;
|
let peerLeaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let ownRingNotifEvent = findLatestOwnRingNotifEvent(
|
||||||
|
selfId,
|
||||||
|
callEmbed.room.getLiveTimeline()?.getEvents() ?? []
|
||||||
|
);
|
||||||
|
let pendingDeclineForNotifEventId: string | undefined;
|
||||||
|
|
||||||
const performHangup = () => {
|
const performHangup = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|
@ -108,12 +127,40 @@ export const useCallerAutoHangup = (): void => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const maybeTrackOwnRing = (ev: MatrixEvent): void => {
|
||||||
|
if (ev.getRoomId() !== roomId) return;
|
||||||
|
if (ev.getType() !== EventType.RTCNotification) return;
|
||||||
|
if (ev.getSender() !== selfId) return;
|
||||||
|
const content = ev.getContent<IRTCNotificationContent>();
|
||||||
|
if (content.notification_type !== 'ring') return;
|
||||||
|
ownRingNotifEvent = ev;
|
||||||
|
if (pendingDeclineForNotifEventId === ev.getId()) {
|
||||||
|
performHangup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const maybeHangupOnDecline = (ev: MatrixEvent): void => {
|
const maybeHangupOnDecline = (ev: MatrixEvent): void => {
|
||||||
// onDecrypted fires for every decrypted event client-wide; gate on room
|
// onDecrypted fires for every decrypted event client-wide; gate on room
|
||||||
// first to avoid walking type/sender on unrelated rooms.
|
// first to avoid walking type/sender on unrelated rooms.
|
||||||
if (ev.getRoomId() !== roomId) return;
|
if (ev.getRoomId() !== roomId) return;
|
||||||
if (ev.getType() !== EventType.RTCDecline) return;
|
if (ev.getType() !== EventType.RTCDecline) return;
|
||||||
if (ev.getSender() === selfId) return;
|
if (ev.getSender() === selfId) return;
|
||||||
|
const declinedNotifEventId = ev.getRelation()?.event_id;
|
||||||
|
if (!declinedNotifEventId) return;
|
||||||
|
|
||||||
|
// Same-room retries can leave multiple historical ring events behind.
|
||||||
|
// We only honor a decline that targets the ring this embed most recently
|
||||||
|
// sent; older declines must not tear down the new attempt.
|
||||||
|
if (!ownRingNotifEvent) {
|
||||||
|
pendingDeclineForNotifEventId = declinedNotifEventId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (declinedNotifEventId !== ownRingNotifEvent.getId()) {
|
||||||
|
if (ownRingNotifEvent.getAssociatedStatus() !== null) {
|
||||||
|
pendingDeclineForNotifEventId = declinedNotifEventId;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
performHangup();
|
performHangup();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -123,20 +170,30 @@ export const useCallerAutoHangup = (): void => {
|
||||||
// Encrypted RTCDecline lands as m.room.encrypted here; let the Decrypted
|
// Encrypted RTCDecline lands as m.room.encrypted here; let the Decrypted
|
||||||
// handler see it once cleartext is available.
|
// handler see it once cleartext is available.
|
||||||
if (ev.isEncrypted()) {
|
if (ev.isEncrypted()) {
|
||||||
mx.decryptEventIfNeeded(ev).catch(() => {});
|
mx.decryptEventIfNeeded(ev).catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
maybeTrackOwnRing(ev);
|
||||||
maybeHangupOnDecline(ev);
|
maybeHangupOnDecline(ev);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => {
|
const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => {
|
||||||
if (err) return;
|
if (err) return;
|
||||||
|
maybeTrackOwnRing(ev);
|
||||||
maybeHangupOnDecline(ev);
|
maybeHangupOnDecline(ev);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onLocalEchoUpdated: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (ev, room) => {
|
||||||
|
if (!room || room.roomId !== roomId) return;
|
||||||
|
if (ev !== ownRingNotifEvent) return;
|
||||||
|
if (pendingDeclineForNotifEventId !== ev.getId()) return;
|
||||||
|
performHangup();
|
||||||
|
};
|
||||||
|
|
||||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
|
||||||
mx.on(RoomEvent.Timeline, onTimeline);
|
mx.on(RoomEvent.Timeline, onTimeline);
|
||||||
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
|
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
mx.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|
@ -145,6 +202,7 @@ export const useCallerAutoHangup = (): void => {
|
||||||
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
|
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
|
||||||
mx.removeListener(RoomEvent.Timeline, onTimeline);
|
mx.removeListener(RoomEvent.Timeline, onTimeline);
|
||||||
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
mx.removeListener(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
|
||||||
};
|
};
|
||||||
}, [mx, callEmbed, mDirect, setCallEmbed]);
|
}, [mx, callEmbed, mDirect, setCallEmbed]);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,49 +3,7 @@ import { useAtomValue } from 'jotai';
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
import { isNativePlatform } from '../utils/capacitor';
|
import { isNativePlatform } from '../utils/capacitor';
|
||||||
|
|
||||||
// When the in-app incomingCallsAtom drops an entry (user accepted, declined,
|
const SUMMARY_NOTIFICATION_ID = -2147483648;
|
||||||
// other device joined, lifetime expired), also clear the CallStyle notification
|
|
||||||
// the native service posted for that room. The AlarmManager fallback in the
|
|
||||||
// Java service handles killed-process dismiss on lifetime expiry; this hook
|
|
||||||
// covers the live-client paths where JS knows the truth sooner than the alarm.
|
|
||||||
export const useDismissNativeCallNotifications = (): void => {
|
|
||||||
const incoming = useAtomValue(incomingCallsAtom);
|
|
||||||
const prevRoomsRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextRooms = new Set<string>();
|
|
||||||
incoming.forEach((call) => nextRooms.add(call.roomId));
|
|
||||||
|
|
||||||
const dropped: string[] = [];
|
|
||||||
prevRoomsRef.current.forEach((roomId) => {
|
|
||||||
if (!nextRooms.has(roomId)) dropped.push(roomId);
|
|
||||||
});
|
|
||||||
prevRoomsRef.current = nextRooms;
|
|
||||||
|
|
||||||
if (dropped.length === 0) return;
|
|
||||||
if (!isNativePlatform()) return;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const { PushNotifications } = await import('@capacitor/push-notifications');
|
|
||||||
// Shape mirrors VojoFirebaseMessagingService.showIncomingCallNotification:
|
|
||||||
// tag = "call_" + roomId, id = tag.hashCode(). The Android Capacitor
|
|
||||||
// plugin reads id via JSObject.getInteger (PushNotificationsPlugin.java
|
|
||||||
// line 166), so the id must go over the bridge as a number — the TS
|
|
||||||
// type declares string, hence the cast.
|
|
||||||
const notifications = dropped.map((roomId) => {
|
|
||||||
const tag = `call_${roomId}`;
|
|
||||||
return { id: javaStringHashCode(tag), tag };
|
|
||||||
});
|
|
||||||
await PushNotifications.removeDeliveredNotifications({
|
|
||||||
notifications: notifications as unknown as Parameters<
|
|
||||||
typeof PushNotifications.removeDeliveredNotifications
|
|
||||||
>[0]['notifications'],
|
|
||||||
}).catch(() => {
|
|
||||||
/* best-effort — alarm fallback still dismisses at lifetime expiry */
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
}, [incoming]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reproduces java.lang.String#hashCode so JS-side ids match the tag ids the
|
// Reproduces java.lang.String#hashCode so JS-side ids match the tag ids the
|
||||||
// Java service computed. Must stay in sync with CallCancelReceiver / the
|
// Java service computed. Must stay in sync with CallCancelReceiver / the
|
||||||
|
|
@ -58,3 +16,52 @@ function javaStringHashCode(s: string): number {
|
||||||
}
|
}
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep native CallStyle in sync with the JS-owned incoming-calls atom. When JS
|
||||||
|
// either loses a ring (accepted, declined, other device joined, lifetime
|
||||||
|
// expired) OR newly takes ownership of one after the app returns foreground,
|
||||||
|
// clear the system notification for that room. The AlarmManager fallback in the
|
||||||
|
// Java service still handles killed-process dismiss on lifetime expiry.
|
||||||
|
export const useDismissNativeCallNotifications = (): void => {
|
||||||
|
const incoming = useAtomValue(incomingCallsAtom);
|
||||||
|
const prevRoomsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextRooms = new Set<string>();
|
||||||
|
incoming.forEach((call) => nextRooms.add(call.roomId));
|
||||||
|
|
||||||
|
const changed: string[] = [];
|
||||||
|
prevRoomsRef.current.forEach((roomId) => {
|
||||||
|
if (!nextRooms.has(roomId)) changed.push(roomId);
|
||||||
|
});
|
||||||
|
nextRooms.forEach((roomId) => {
|
||||||
|
if (!prevRoomsRef.current.has(roomId)) changed.push(roomId);
|
||||||
|
});
|
||||||
|
prevRoomsRef.current = nextRooms;
|
||||||
|
|
||||||
|
if (changed.length === 0) return;
|
||||||
|
if (!isNativePlatform()) return;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { PushNotifications } = await import('@capacitor/push-notifications');
|
||||||
|
// Shape mirrors VojoFirebaseMessagingService.showIncomingCallNotification:
|
||||||
|
// tag = "call_" + roomId, id = tag.hashCode(). The Android Capacitor
|
||||||
|
// plugin reads id via JSObject.getInteger (PushNotificationsPlugin.java
|
||||||
|
// line 166), so the id must go over the bridge as a number — the TS
|
||||||
|
// type declares string, hence the cast.
|
||||||
|
const notifications = changed.map((roomId) => {
|
||||||
|
const tag = `call_${roomId}`;
|
||||||
|
let id = javaStringHashCode(tag);
|
||||||
|
if (id === SUMMARY_NOTIFICATION_ID) id += 1;
|
||||||
|
return { id, tag };
|
||||||
|
});
|
||||||
|
await PushNotifications.removeDeliveredNotifications({
|
||||||
|
notifications: notifications as unknown as Parameters<
|
||||||
|
typeof PushNotifications.removeDeliveredNotifications
|
||||||
|
>[0]['notifications'],
|
||||||
|
}).catch(() => {
|
||||||
|
/* best-effort — alarm fallback still dismisses at lifetime expiry */
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [incoming]);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,16 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
|
||||||
import { useCallStart } from './useCallEmbed';
|
|
||||||
import { useCallPreferencesAtom } from '../state/hooks/callPreferences';
|
|
||||||
|
|
||||||
export const useDmCallStart = () => {
|
export const useDmCallStart = () => {
|
||||||
const mx = useMatrixClient();
|
const switchOrStartDmCall = useSwitchOrStartDmCall();
|
||||||
const startCall = useCallStart(true, true);
|
|
||||||
const callPref = useAtomValue(useCallPreferencesAtom());
|
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
const room = mx.getRoom(roomId);
|
switchOrStartDmCall(roomId).catch((err: unknown) => {
|
||||||
if (!room) {
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[dm-call] room not found', roomId);
|
console.warn('[dm-call] switch/start failed', err);
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
startCall(room, callPref);
|
|
||||||
},
|
},
|
||||||
[mx, startCall, callPref]
|
[switchOrStartDmCall]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -90,10 +90,7 @@ const resolveCallId = async (
|
||||||
// Race: ring arrived before /sync delivered the membership state event.
|
// Race: ring arrived before /sync delivered the membership state event.
|
||||||
const session = mx.matrixRTC.getRoomSession(room);
|
const session = mx.matrixRTC.getRoomSession(room);
|
||||||
const waited = await new Promise<string | undefined>((resolve) => {
|
const waited = await new Promise<string | undefined>((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
|
|
||||||
resolve(undefined);
|
|
||||||
}, 5000);
|
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
const found = findCallIdSync(mx, room, sender, membershipEventId);
|
const found = findCallIdSync(mx, room, sender, membershipEventId);
|
||||||
if (found !== undefined) {
|
if (found !== undefined) {
|
||||||
|
|
@ -102,6 +99,10 @@ const resolveCallId = async (
|
||||||
resolve(found);
|
resolve(found);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
|
||||||
|
resolve(undefined);
|
||||||
|
}, 5000);
|
||||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, handler);
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, handler);
|
||||||
});
|
});
|
||||||
if (waited !== undefined) return waited;
|
if (waited !== undefined) return waited;
|
||||||
|
|
@ -131,8 +132,6 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
|
|
||||||
const mDirectRef = useRef(mDirect);
|
const mDirectRef = useRef(mDirect);
|
||||||
mDirectRef.current = mDirect;
|
mDirectRef.current = mDirect;
|
||||||
const inCallRef = useRef(callEmbed !== undefined);
|
|
||||||
inCallRef.current = callEmbed !== undefined;
|
|
||||||
|
|
||||||
const registryRef = useRef<Map<string, RegistryEntry>>(new Map());
|
const registryRef = useRef<Map<string, RegistryEntry>>(new Map());
|
||||||
|
|
||||||
|
|
@ -220,7 +219,6 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
// Only DM ring — group call notifications use 'notification' type and are out of scope.
|
// Only DM ring — group call notifications use 'notification' type and are out of scope.
|
||||||
if (content.notification_type !== 'ring') return;
|
if (content.notification_type !== 'ring') return;
|
||||||
if (!mDirectRef.current.has(room.roomId)) return;
|
if (!mDirectRef.current.has(room.roomId)) return;
|
||||||
if (inCallRef.current) return;
|
|
||||||
if (isRtcNotificationExpired(ev)) return;
|
if (isRtcNotificationExpired(ev)) return;
|
||||||
|
|
||||||
// Already participating in the room session → suppress duplicate toast.
|
// Already participating in the room session → suppress duplicate toast.
|
||||||
|
|
@ -241,7 +239,6 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
// Re-check membership after the (possibly networked) callId resolve —
|
// Re-check membership after the (possibly networked) callId resolve —
|
||||||
// a join event from another device could have landed during the await.
|
// a join event from another device could have landed during the await.
|
||||||
if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
|
if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
|
||||||
if (inCallRef.current) return;
|
|
||||||
|
|
||||||
const key = getIncomingCallKey(callId, room.roomId);
|
const key = getIncomingCallKey(callId, room.roomId);
|
||||||
if (registry.has(key)) return;
|
if (registry.has(key)) return;
|
||||||
|
|
@ -274,7 +271,7 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
// Encrypted events land here as m.room.encrypted; kick decryption and let
|
// Encrypted events land here as m.room.encrypted; kick decryption and let
|
||||||
// the Decrypted handler pick them up once the cleartext is available.
|
// the Decrypted handler pick them up once the cleartext is available.
|
||||||
if (ev.isEncrypted()) {
|
if (ev.isEncrypted()) {
|
||||||
mx.decryptEventIfNeeded(ev).catch(() => {});
|
mx.decryptEventIfNeeded(ev).catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
processEvent(ev, room);
|
processEvent(ev, room);
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,49 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { App } from '@capacitor/app';
|
import { App } from '@capacitor/app';
|
||||||
import { pendingCallActionAtom } from '../state/pendingCallAction';
|
import { pendingCallActionAtom } from '../state/pendingCallAction';
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
import { useDmCallStart } from './useDmCallStart';
|
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { isNativePlatform } from '../utils/capacitor';
|
import { isNativePlatform } from '../utils/capacitor';
|
||||||
|
import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall';
|
||||||
|
|
||||||
// Consumes pending call actions emitted by the native Android push-action
|
// Consumes pending call actions emitted by the native Android push-action
|
||||||
// listener (see usePushNotifications.ts). Must be mounted inside CallEmbedProvider
|
// listener (see usePushNotifications.ts). Must be mounted inside CallEmbedProvider
|
||||||
// so useDmCallStart can reach the embed atom.
|
// so useSwitchOrStartDmCall can reach the embed atom.
|
||||||
export const usePendingCallActionConsumer = (): void => {
|
export const usePendingCallActionConsumer = (): void => {
|
||||||
const pending = useAtomValue(pendingCallActionAtom);
|
const pending = useAtomValue(pendingCallActionAtom);
|
||||||
const setPending = useSetAtom(pendingCallActionAtom);
|
const setPending = useSetAtom(pendingCallActionAtom);
|
||||||
const setIncoming = useSetAtom(incomingCallsAtom);
|
const setIncoming = useSetAtom(incomingCallsAtom);
|
||||||
const startDmCall = useDmCallStart();
|
const switchOrStartDmCall = useSwitchOrStartDmCall();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
if (pending.kind === 'answer') {
|
if (pending.kind === 'answer') {
|
||||||
// In-app strip (if any) is stale after accept — drop it so the overlay
|
const { roomId, notifEventId } = pending;
|
||||||
// can own the UX without a parallel decline button lingering.
|
setPending(undefined);
|
||||||
setIncoming({ type: 'REMOVE_BY_ROOM', roomId: pending.roomId });
|
switchOrStartDmCall(roomId)
|
||||||
startDmCall(pending.roomId);
|
.then(() => {
|
||||||
} else {
|
if (notifEventId) {
|
||||||
|
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIncoming({ type: 'REMOVE_BY_ROOM', roomId });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[call] native answer switch/start failed', err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { roomId, notifEventId } = pending;
|
||||||
|
setPending(undefined);
|
||||||
// Unreachable in practice after techdebt §5.35 landed: every Decline
|
// Unreachable in practice after techdebt §5.35 landed: every Decline
|
||||||
// button press now fires CallDeclineReceiver via PendingIntent.getBroadcast,
|
// button press now fires CallDeclineReceiver via PendingIntent.getBroadcast,
|
||||||
// so MainActivity never boots and `pushNotificationActionPerformed` never
|
// so MainActivity never boots and `pushNotificationActionPerformed` never
|
||||||
// fires with call_action='decline'. Nothing else queues a decline onto
|
// fires with call_action='decline'. Nothing else queues a decline onto
|
||||||
// pendingCallActionAtom. Kept as a safety-net in case a future JS-path
|
// pendingCallActionAtom. Kept as a safety-net in case a future JS-path
|
||||||
// (in-app banner decline, retry flow, etc.) starts emitting here.
|
// (in-app banner decline, retry flow, etc.) starts emitting here.
|
||||||
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: pending.notifEventId });
|
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId });
|
||||||
// Fire-and-minimize: dispatch the decline then minimize the app once the
|
// Fire-and-minimize: dispatch the decline then minimize the app once the
|
||||||
// request settles (success OR failure). Minimizing before sendRtcDecline
|
// request settles (success OR failure). Minimizing before sendRtcDecline
|
||||||
// resolves risks the WebView getting paused mid-request on slower devices;
|
// resolves risks the WebView getting paused mid-request on slower devices;
|
||||||
|
|
@ -42,7 +56,7 @@ export const usePendingCallActionConsumer = (): void => {
|
||||||
/* minimize not supported / already in background */
|
/* minimize not supported / already in background */
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
mx.sendRtcDecline(pending.roomId, pending.notifEventId).then(
|
mx.sendRtcDecline(roomId, notifEventId).then(
|
||||||
() => minimize(),
|
() => minimize(),
|
||||||
(err: unknown) => {
|
(err: unknown) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|
@ -50,7 +64,5 @@ export const usePendingCallActionConsumer = (): void => {
|
||||||
minimize();
|
minimize();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}, [pending, setPending, setIncoming, switchOrStartDmCall, mx]);
|
||||||
setPending(undefined);
|
|
||||||
}, [pending, setPending, setIncoming, startDmCall, mx]);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -341,14 +341,19 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
notif_event_id?: string;
|
notif_event_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Native CallStyle Answer → open the room and queue an auto-join via
|
// Native CallStyle Answer → open the room and queue a JS-side
|
||||||
|
// switch/start via
|
||||||
// pendingCallActionAtom. The consumer hook picks it up once the
|
// pendingCallActionAtom. The consumer hook picks it up once the
|
||||||
// CallEmbedProvider tree is mounted. DM rooms live in the Direct tab
|
// CallEmbedProvider tree is mounted. DM rooms live in the Direct tab
|
||||||
// route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
|
// route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
|
||||||
// it doesn't have in its left-rail, hence the DM path here.
|
// it doesn't have in its left-rail, hence the DM path here.
|
||||||
if (data.call_action === 'answer' && data.room_id) {
|
if (data.call_action === 'answer' && data.room_id) {
|
||||||
navigate(getDirectRoomPath(data.room_id));
|
navigate(getDirectRoomPath(data.room_id));
|
||||||
setPendingCallAction({ kind: 'answer', roomId: data.room_id });
|
setPendingCallAction({
|
||||||
|
kind: 'answer',
|
||||||
|
roomId: data.room_id,
|
||||||
|
notifEventId: data.notif_event_id,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,10 +448,12 @@ export function usePushNotificationsLifecycle(): void {
|
||||||
|
|
||||||
// Display is handled entirely by the native VojoFirebaseMessagingService
|
// Display is handled entirely by the native VojoFirebaseMessagingService
|
||||||
// (which fires for both backgrounded and killed process states via FCM's
|
// (which fires for both backgrounded and killed process states via FCM's
|
||||||
// direct delivery). JS-side pushNotificationReceived would double-fire
|
// direct delivery). We intentionally do NOT subscribe to
|
||||||
// alongside native when the bridge is alive but backgrounded, so we don't
|
// `pushNotificationReceived` here: doing so would create a second incoming
|
||||||
// subscribe to it here — foreground dedup stays the in-app responsibility
|
// call delivery path next to the native service / timeline listener pair.
|
||||||
// (MessageNotifications cards), everything else goes through the native path.
|
// Foreground incoming-call UX is owned by the in-app strip via
|
||||||
|
// useIncomingRtcNotifications; background/killed UX is owned by native
|
||||||
|
// CallStyle. Keep this invariant unless the whole dedup model changes.
|
||||||
|
|
||||||
// Kick off token delivery on startup if push was previously enabled and
|
// Kick off token delivery on startup if push was previously enabled and
|
||||||
// permission is still granted. The listener above picks up the token.
|
// permission is still granted. The listener above picks up the token.
|
||||||
|
|
|
||||||
146
src/app/hooks/useSwitchOrStartDmCall.ts
Normal file
146
src/app/hooks/useSwitchOrStartDmCall.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// DM call entry point that unifies start, join and switch flows.
|
||||||
|
//
|
||||||
|
// Contract (dm_calls_techdebt.md §5.36):
|
||||||
|
// - no prev embed → start a new DM call
|
||||||
|
// - prev.roomId === arg → no-op (healthy same-room click)
|
||||||
|
// - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
|
||||||
|
// dispose prev, then start new
|
||||||
|
//
|
||||||
|
// Switch barrier waits for one of two success signals:
|
||||||
|
// 1. MembershipsChanged removing our (user, device) from prev room session
|
||||||
|
// — server-authoritative, this is what the peer actually observes
|
||||||
|
// 2. action:im.vector.hangup from the widget — widget-side confirmation,
|
||||||
|
// including pre-join / not-yet-member states where there is nothing to
|
||||||
|
// observe via MembershipsChanged
|
||||||
|
// 3. 3s timeout — fail-closed: abort switch, do not start the new call
|
||||||
|
//
|
||||||
|
// Listeners are armed BEFORE hangup() to avoid missing a fast action ack.
|
||||||
|
//
|
||||||
|
// The function is serialized via inFlightRef: concurrent double-taps await the
|
||||||
|
// same promise and then re-enter. After re-entry the same-room branch usually
|
||||||
|
// makes the second tap a noop, matching user intent.
|
||||||
|
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useAtomValue, useStore } from 'jotai';
|
||||||
|
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
|
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useTheme } from './useTheme';
|
||||||
|
import { createCallEmbed, useCallEmbedRef } from './useCallEmbed';
|
||||||
|
import { useCallPreferencesAtom } from '../state/hooks/callPreferences';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
|
||||||
|
const SWITCH_LEAVE_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
const createSwitchTimeoutError = (roomId: string): Error =>
|
||||||
|
new Error(`[dm-call] switch timed out waiting for clean leave in ${roomId}`);
|
||||||
|
|
||||||
|
export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const theme = useTheme();
|
||||||
|
const store = useStore();
|
||||||
|
const callEmbedRef = useCallEmbedRef();
|
||||||
|
const callPref = useAtomValue(useCallPreferencesAtom());
|
||||||
|
|
||||||
|
const inFlightRef = useRef<Promise<void> | undefined>(undefined);
|
||||||
|
|
||||||
|
const waitLeave = useCallback(
|
||||||
|
(prev: CallEmbed): Promise<void> => {
|
||||||
|
const session = mx.matrixRTC.getRoomSession(prev.room);
|
||||||
|
const selfUserId = mx.getSafeUserId();
|
||||||
|
const selfDeviceId = mx.getDeviceId();
|
||||||
|
const selfPresent = (memberships: CallMembership[]): boolean =>
|
||||||
|
memberships.some(
|
||||||
|
(m) => m.sender === selfUserId && (!selfDeviceId || m.deviceId === selfDeviceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let done = false;
|
||||||
|
// Assigned once listeners are wired — set to a real cleanup below
|
||||||
|
// so the pre-wire window is never reachable (finish can only fire
|
||||||
|
// after the listeners/timer exist).
|
||||||
|
let cleanup: () => void = () => undefined;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const fail = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
cleanup();
|
||||||
|
reject(createSwitchTimeoutError(prev.roomId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMemberships = (_old: CallMembership[], next: CallMembership[]): void => {
|
||||||
|
if (!selfPresent(next)) finish();
|
||||||
|
};
|
||||||
|
const onHangupAction = (): void => finish();
|
||||||
|
|
||||||
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
|
||||||
|
prev.call.on('action:im.vector.hangup', onHangupAction);
|
||||||
|
const timer = setTimeout(fail, SWITCH_LEAVE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
|
||||||
|
prev.call.off('action:im.vector.hangup', onHangupAction);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
prev.hangup().catch((err: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[dm-call] hangup transport fail (barrier still armed)', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const doSwitchOrStart = useCallback(
|
||||||
|
async (roomId: string): Promise<void> => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[dm-call] room not found', roomId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = store.get(callEmbedAtom);
|
||||||
|
if (prev?.roomId === roomId) return;
|
||||||
|
|
||||||
|
const container = callEmbedRef.current;
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('Failed to start call, No embed container element found!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev) {
|
||||||
|
await waitLeave(prev);
|
||||||
|
prev.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true);
|
||||||
|
store.set(callEmbedAtom, embed);
|
||||||
|
},
|
||||||
|
[mx, store, callEmbedRef, callPref, theme, waitLeave]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(roomId: string): Promise<void> => {
|
||||||
|
const enter = (): Promise<void> => {
|
||||||
|
if (inFlightRef.current) {
|
||||||
|
return inFlightRef.current.then(enter, enter);
|
||||||
|
}
|
||||||
|
const task = doSwitchOrStart(roomId);
|
||||||
|
const tracked: Promise<void> = task.finally(() => {
|
||||||
|
if (inFlightRef.current === tracked) inFlightRef.current = undefined;
|
||||||
|
});
|
||||||
|
inFlightRef.current = tracked;
|
||||||
|
return tracked;
|
||||||
|
};
|
||||||
|
return enter();
|
||||||
|
},
|
||||||
|
[doSwitchOrStart]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before
|
// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before
|
||||||
// `CallStatusRenderer` so the strip stacks above the in-call pill.
|
// `CallStatusRenderer` so the strip stacks above the in-call pill.
|
||||||
//
|
//
|
||||||
// On native Android the OS CallStyle notification (see
|
// IncomingCallStripRenderer is platform-agnostic: if JS knows about an incoming
|
||||||
// VojoFirebaseMessagingService) owns the incoming-call UX end-to-end: heads-up,
|
// ring, we render the in-app strip. On Android the native FCM service decides
|
||||||
// ringtone, Answer/Decline buttons, full-screen wakeup. Rendering the in-app
|
// independently whether to surface a system CallStyle notification; when the
|
||||||
// strip in parallel just duplicates UI and plays a second ringtone on top of
|
// app is foregrounded it now suppresses that banner, so this renderer no longer
|
||||||
// the system one — so the renderer is a no-op on native.
|
// needs to mirror native foreground policy in JS.
|
||||||
//
|
//
|
||||||
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no
|
// 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
|
// user gesture yet), the ring is silent — strip is still visible but user may
|
||||||
|
|
@ -20,7 +20,6 @@ import { Box } from 'folds';
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { IncomingCallStrip } from '../features/call-status';
|
import { IncomingCallStrip } from '../features/call-status';
|
||||||
import { isNativePlatform } from '../utils/capacitor';
|
|
||||||
// eslint-disable-next-line import/no-relative-packages
|
// eslint-disable-next-line import/no-relative-packages
|
||||||
import RingSoundOgg from '../../../public/sound/ring.ogg';
|
import RingSoundOgg from '../../../public/sound/ring.ogg';
|
||||||
// eslint-disable-next-line import/no-relative-packages
|
// eslint-disable-next-line import/no-relative-packages
|
||||||
|
|
@ -32,12 +31,11 @@ export function IncomingCallStripRenderer() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
const hasIncoming = incoming.size > 0;
|
const hasIncoming = incoming.size > 0;
|
||||||
const suppress = isNativePlatform();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
if (hasIncoming && !suppress) {
|
if (hasIncoming) {
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
audio.play().catch(() => {
|
audio.play().catch(() => {
|
||||||
// autoplay blocked — strip UI still visible
|
// autoplay blocked — strip UI still visible
|
||||||
|
|
@ -46,9 +44,7 @@ export function IncomingCallStripRenderer() {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
}
|
}
|
||||||
}, [hasIncoming, suppress]);
|
}, [hasIncoming]);
|
||||||
|
|
||||||
if (suppress) return null;
|
|
||||||
|
|
||||||
const entries = Array.from(incoming.values());
|
const entries = Array.from(incoming.values());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import { CallControlState } from './CallControlState';
|
||||||
export class CallEmbed {
|
export class CallEmbed {
|
||||||
private mx: MatrixClient;
|
private mx: MatrixClient;
|
||||||
|
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
public readonly call: ClientWidgetApi;
|
public readonly call: ClientWidgetApi;
|
||||||
|
|
||||||
public readonly iframe: HTMLIFrameElement;
|
public readonly iframe: HTMLIFrameElement;
|
||||||
|
|
@ -242,6 +244,9 @@ export class CallEmbed {
|
||||||
* @param opts
|
* @param opts
|
||||||
*/
|
*/
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
|
||||||
this.disposables.forEach((disposable) => {
|
this.disposables.forEach((disposable) => {
|
||||||
disposable();
|
disposable();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import { atom } from 'jotai';
|
||||||
// Bridge between the native Capacitor push-action listener (which fires outside
|
// Bridge between the native Capacitor push-action listener (which fires outside
|
||||||
// any React render context) and the in-app call flow. The listener sets the
|
// any React render context) and the in-app call flow. The listener sets the
|
||||||
// atom; usePendingCallActionConsumer — mounted inside CallEmbedProvider — reads
|
// atom; usePendingCallActionConsumer — mounted inside CallEmbedProvider — reads
|
||||||
// it and triggers useDmCallStart / mx.sendRtcDecline.
|
// it and triggers switch-or-start / mx.sendRtcDecline.
|
||||||
export type PendingCallAction =
|
export type PendingCallAction =
|
||||||
| { kind: 'answer'; roomId: string }
|
| { kind: 'answer'; roomId: string; notifEventId?: string }
|
||||||
| { kind: 'decline'; roomId: string; notifEventId: string };
|
| { kind: 'decline'; roomId: string; notifEventId: string };
|
||||||
|
|
||||||
export const pendingCallActionAtom = atom<PendingCallAction | undefined>(undefined);
|
export const pendingCallActionAtom = atom<PendingCallAction | undefined>(undefined);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue