vojo/src/app/pages/IncomingCallStripRenderer.tsx

94 lines
3.9 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 } from 'react';
import { useAtomValue } from 'jotai';
import { incomingCallsAtom } from '../state/incomingCalls';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAppActive } from '../hooks/useAppActive';
import { IncomingCallStrip } from '../features/call-status';
import { getIncomingCallKey } from '../utils/rtcNotification';
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);
// Foreground/background signal — shared `useAppActive` registers a
// single Capacitor App.pause/resume listener (Android+iOS) and
// App.appStateChange (web). Used only to gate Android ring audio
// below; iOS / web never read the value because they have no native
// ring surface.
const appActive = useAppActive();
const hasIncoming = incoming.size > 0;
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 &&
entries.map((call) => {
const room = mx.getRoom(call.roomId);
if (!room) return null;
return (
<IncomingCallStrip
key={getIncomingCallKey(call.callId, call.roomId)}
call={call}
room={room}
/>
);
})}
</>
);
}