141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
// Top-level renderer for incoming DM call strips + ringtone audio.
|
|
//
|
|
// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before
|
|
// `CallStatusRenderer` so the strip stacks above the in-call pill.
|
|
//
|
|
// IncomingCallStripRenderer is platform-agnostic: if JS knows about an incoming
|
|
// ring, we render the in-app strip. On Android the native FCM service decides
|
|
// independently whether to surface a system CallStyle notification; when the
|
|
// app is foregrounded it suppresses that banner, so strip render itself does
|
|
// not need to mirror foreground policy in JS.
|
|
//
|
|
// Ring audio mirrors foreground policy on Android — gated on `appActive`
|
|
// so the native CallStyle ringtone owns UX in background and the JS
|
|
// <audio> doesn't double-ring during the grace window after backgrounding
|
|
// while the WebView still processes /sync. On web / iOS there is no
|
|
// native ring surface, so audio plays regardless of visibility.
|
|
//
|
|
// Known gap: if the browser blocks `audio.play()` (cold page load, no user
|
|
// gesture yet), the ring is silent — strip is still visible but user may
|
|
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications)
|
|
// is Phase 3 polish.
|
|
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useAtomValue } from 'jotai';
|
|
import { Box } from 'folds';
|
|
import { App } from '@capacitor/app';
|
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
import { IncomingCallStrip } from '../features/call-status';
|
|
import { isAndroidPlatform } from '../utils/capacitor';
|
|
// eslint-disable-next-line import/no-relative-packages
|
|
import RingSoundOgg from '../../../public/sound/ring.ogg';
|
|
// eslint-disable-next-line import/no-relative-packages
|
|
import RingSoundMp3 from '../../../public/sound/ring.mp3';
|
|
|
|
export function IncomingCallStripRenderer() {
|
|
const mx = useMatrixClient();
|
|
const incoming = useAtomValue(incomingCallsAtom);
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const [appActive, setAppActive] = useState(
|
|
() => typeof document === 'undefined' || document.visibilityState === 'visible'
|
|
);
|
|
|
|
const hasIncoming = incoming.size > 0;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
// Android: pause/resume fire on BridgeActivity.onPause/onResume via
|
|
// AppPlugin.handleOnPause/Resume — same Activity callbacks that flip
|
|
// MainActivity.isInForeground — reaching JS within ms via the WebView
|
|
// bridge. appStateChange on Android only fires in onStop (tens of ms to
|
|
// ~1s after onPause), which would let JS audio race the native ringtone
|
|
// in the "just backgrounded" window and double-ring.
|
|
// iOS/web keeps appStateChange because there it maps to
|
|
// willResignActive/didBecomeActive (iOS) or document.visibilitychange
|
|
// (web). The Capacitor pause/resume events on iOS bind to
|
|
// didEnterBackground/willEnterForeground — a different edge.
|
|
// Race-guard: a real lifecycle event arriving before getState() resolves
|
|
// must win over the stale snapshot.
|
|
let sawLifecycleEvent = false;
|
|
const handles: Array<{ remove: () => void }> = [];
|
|
const track = (h: { remove: () => void }) => {
|
|
if (cancelled) h.remove();
|
|
else handles.push(h);
|
|
};
|
|
|
|
const apply = (isActive: boolean) => {
|
|
sawLifecycleEvent = true;
|
|
if (!cancelled) setAppActive(isActive);
|
|
};
|
|
|
|
if (isAndroidPlatform()) {
|
|
App.addListener('pause', () => apply(false)).then(track);
|
|
App.addListener('resume', () => apply(true)).then(track);
|
|
} else {
|
|
App.addListener('appStateChange', ({ isActive }) => apply(isActive)).then(track);
|
|
}
|
|
|
|
App.getState()
|
|
.then((state) => {
|
|
if (!cancelled && !sawLifecycleEvent) setAppActive(state.isActive);
|
|
})
|
|
.catch(() => {
|
|
/* web fallback handled by initial document.visibilityState */
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
handles.forEach((h) => h.remove());
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
// Platform split on the audio gate:
|
|
// - Android: gate on appActive. When backgrounded the native CallStyle
|
|
// ringtone (via vojo_calls_v2 channel) takes over, so JS audio must
|
|
// stop to avoid double-ring.
|
|
// - web / iOS: no native fallback exists. Gating on visibility here
|
|
// silenced the only ring source whenever the user switched tabs —
|
|
// user-reported regression. Keep audio playing regardless of
|
|
// visibility on non-Android platforms.
|
|
const platformGatedActive = isAndroidPlatform() ? appActive : true;
|
|
if (hasIncoming && platformGatedActive) {
|
|
audio.currentTime = 0;
|
|
audio.play().catch(() => {
|
|
// autoplay blocked — strip UI still visible
|
|
});
|
|
} else {
|
|
audio.pause();
|
|
}
|
|
}, [hasIncoming, appActive]);
|
|
|
|
const entries = Array.from(incoming.values());
|
|
|
|
return (
|
|
<>
|
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
|
<audio ref={audioRef} loop preload="auto" style={{ display: 'none' }}>
|
|
<source src={RingSoundOgg} type="audio/ogg" />
|
|
<source src={RingSoundMp3} type="audio/mpeg" />
|
|
</audio>
|
|
{hasIncoming && (
|
|
<Box direction="Column" shrink="No">
|
|
{entries.map((call) => {
|
|
const room = mx.getRoom(call.roomId);
|
|
if (!room) return null;
|
|
return (
|
|
<IncomingCallStrip
|
|
key={`call_${call.callId}_${call.roomId}`}
|
|
call={call}
|
|
room={room}
|
|
/>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</>
|
|
);
|
|
}
|