Gate incoming-call ring audio on App.isActive to stop double-ring in the brief window after app backgrounding

This commit is contained in:
v.lagerev 2026-04-23 00:50:55 +03:00
parent e8188d7361
commit 2836411830

View file

@ -6,17 +6,24 @@
// 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 now suppresses that banner, so this renderer no longer
// needs to mirror native foreground policy in JS.
// app is foregrounded it suppresses that banner, so strip render itself does
// not need to mirror foreground policy in JS.
//
// Ring audio DOES mirror foreground policy — gated on `appActive` (techdebt
// §5.39): when the app/tab is hidden the platform surface (Android CallStyle /
// web SW push) owns ringtone UX, and playing the in-app <audio> on top would
// double-ring during the grace window after backgrounding while the WebView
// still processes /sync.
//
// 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
// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is
// Phase 3 polish.
import React, { useEffect, useRef } from 'react';
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';
@ -29,22 +36,58 @@ 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;
// Listener registration and getState() race: if a real appStateChange
// fires before getState() resolves, the stale getState() snapshot would
// overwrite the fresh event and re-open §5.39 (e.g. background → listener
// sets false → late getState() resolves true → audio re-enabled).
let sawAppStateEvent = false;
let remove: (() => void) | undefined;
App.addListener('appStateChange', ({ isActive }) => {
sawAppStateEvent = true;
if (!cancelled) setAppActive(isActive);
}).then((handle) => {
if (cancelled) handle.remove();
else
remove = () => {
handle.remove();
};
});
App.getState()
.then((state) => {
if (!cancelled && !sawAppStateEvent) setAppActive(state.isActive);
})
.catch(() => {
/* web fallback handled by initial document.visibilityState */
});
return () => {
cancelled = true;
remove?.();
};
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (hasIncoming) {
if (hasIncoming && appActive) {
audio.currentTime = 0;
audio.play().catch(() => {
// autoplay blocked — strip UI still visible
});
} else {
audio.pause();
audio.currentTime = 0;
}
}, [hasIncoming]);
}, [hasIncoming, appActive]);
const entries = Array.from(incoming.values());