diff --git a/src/api/rest.ts b/src/api/rest.ts index 99680ea..c85ce8c 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -178,6 +178,12 @@ export interface ChatSettings { lastSeenVisibility: LastSeenVisibility; screenshotProtection: boolean; defaultDisappearingSeconds: number; + /** + * "Help translate my captions": this device may translate its OWN call captions for a partner whose + * device cannot translate the pair (the §12.5 sender fallback). Default off — it uses this device's + * battery and may download translation models. See OneHookBackend/docs/voice-video-calls.md D13. + */ + helpTranslateCaptions: boolean; } export const ChatSettingsApi = { diff --git a/src/app/AppContent.tsx b/src/app/AppContent.tsx index c81ba63..0e2a8ab 100644 --- a/src/app/AppContent.tsx +++ b/src/app/AppContent.tsx @@ -27,6 +27,7 @@ import { DesktopSidebar } from './DesktopSidebar'; import { SearchDrawer } from './SearchDrawer'; import { CreateModal } from './CreateModal'; import { LogoutConfirmDialog } from './LogoutConfirmDialog'; +import { CallProvider } from './CallProvider'; import { useTheme } from '../lib/theme'; export function AppContent() { @@ -249,6 +250,7 @@ export function AppContent() { const userPhoto = currentUser.photos?.[0] || (currentUser as any).pictures?.[0]; return ( +
{/* Common Full-Width Header expanding from left to right with OneHook logo in the centre */}
+
); } diff --git a/src/app/CallProvider.tsx b/src/app/CallProvider.tsx new file mode 100644 index 0000000..fe9d36f --- /dev/null +++ b/src/app/CallProvider.tsx @@ -0,0 +1,214 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type ReactNode, +} from 'react'; +import { CallApi } from '../api/calls'; +import { StateApi } from '../api/state'; +import { ProfileApi } from '../api/profile'; +import { ChatSettingsApi } from '../api/rest'; +import { ChatEncryptionManager } from '../lib/chat-encryption'; +import { CallSession, type CallSessionDeps } from '../lib/call-session'; +import { + GlobalCallController, + type SessionFactoryParams, + type StartCallArgs, +} from '../lib/call-controller'; +import { createWebCaptionPorts, webCaptionsSupported } from '../lib/call-captions-web'; +import { friendlyCallError, friendlyEndReason } from '../lib/call-errors'; +import { useToast } from '../components/common/Toast'; +import { Screen, trackScreenView } from '../lib/analytics/analytics'; +import { IncomingCallModal } from '../components/call/IncomingCallModal'; +import { InCallScreen } from '../components/call/InCallScreen'; + +/** + * App-level call provider. Mounted ONCE for a signed-in user (in {@link AppContent}), it owns the + * single {@link GlobalCallController} so incoming calls ring on any screen — not only while a + * conversation is open (OneHookBackend/docs/voice-video-calls.md §10). It renders the ring modal and + * the in-call surface here, at the app root, and exposes {@link useCall} so a conversation's call + * buttons start a call through the same controller. + */ +interface CallContextValue { + /** True while any call is being set up or is live (so conversation buttons disable). */ + active: boolean; + /** The match a call is currently active for, if any. */ + activeMatchId: string | null; + /** Start an outbound call from a conversation. */ + startCall(args: StartCallArgs): Promise; + /** Increments whenever a call ends, so a conversation can refresh its call-history timeline. */ + historyNonce: number; +} + +const CallContext = createContext(null); + +/** Access the app-wide call controller. Throws if used outside {@link CallProvider}. */ +export function useCall(): CallContextValue { + const ctx = useContext(CallContext); + if (!ctx) throw new Error('useCall must be used within a CallProvider.'); + return ctx; +} + +function buildController(currentUserId: string): GlobalCallController { + const deviceLanguage = + (typeof navigator !== 'undefined' && navigator.language) || 'en-US'; + const canSendScreen = + typeof navigator !== 'undefined' && + typeof navigator.mediaDevices?.getDisplayMedia === 'function'; + + const makeSession = (params: SessionFactoryParams): CallSession | null => { + if (typeof navigator === 'undefined' || !navigator.mediaDevices) return null; + const crypto = new ChatEncryptionManager(currentUserId); + const deps: CallSessionDeps = { + api: CallApi, + crypto, + createPeerConnection: (config) => new RTCPeerConnection(config), + getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints), + }; + if (typeof navigator.mediaDevices.getDisplayMedia === 'function') { + deps.getDisplayMedia = (constraints) => navigator.mediaDevices.getDisplayMedia(constraints); + } + return new CallSession(deps, { + role: params.role, + selfUserId: params.selfUserId, + peerId: params.peerId, + matchId: params.matchId, + media: params.media, + callId: params.callId, + callerDeviceId: params.callerDeviceId, + handlers: params.handlers, + }); + }; + + return new GlobalCallController({ + currentUserId, + subscribeToCallEvents: CallApi.subscribeToCallEvents, + declineCall: CallApi.declineCall, + makeSession, + resolvePeer: async (matchId: string) => { + const record: any = await StateApi.getMatch(matchId); + const peerId = record?.userA === currentUserId ? record?.userB : record?.userA; + let peerName = 'Your Match'; + try { + if (peerId) { + const profile: any = await ProfileApi.get(peerId); + peerName = profile?.displayName || profile?.name || 'Your Match'; + } + } catch { + /* best effort — a generic label still rings the call */ + } + return { peerId: peerId || '', peerName }; + }, + friendlyError: friendlyCallError, + makeCaptionPorts: () => createWebCaptionPorts(), + captionsSupported: webCaptionsSupported(), + getHelpTranslateCaptions: async () => { + try { + const settings = await ChatSettingsApi.get(); + return Boolean(settings.helpTranslateCaptions); + } catch { + return false; + } + }, + deviceLanguage, + canSendScreen, + }); +} + +export function CallProvider({ currentUserId, children }: { currentUserId: string; children: ReactNode }) { + const { showToast } = useToast(); + + const controller = useMemo(() => buildController(currentUserId), [currentUserId]); + const subscribe = useMemo(() => controller.subscribe.bind(controller), [controller]); + const getSnapshot = useMemo(() => controller.getSnapshot.bind(controller), [controller]); + const snapshot = useSyncExternalStore(subscribe, getSnapshot); + + useEffect(() => { + controller.start(); + return () => controller.stop(); + }, [controller]); + + // Report a CALL screen_view when the call surface (ring or in-call) first appears. + const callUiVisible = snapshot.active || Boolean(snapshot.incoming); + const callUiWasVisible = useRef(false); + useEffect(() => { + if (callUiVisible && !callUiWasVisible.current) trackScreenView(Screen.CALL); + callUiWasVisible.current = callUiVisible; + }, [callUiVisible]); + + // Surface call errors and non-trivial end reasons (busy, missed, declined…) as toasts. + useEffect(() => { + if (snapshot.error) { + showToast(snapshot.error, 'error'); + controller.clearError(); + } + }, [snapshot.error, controller, showToast]); + + useEffect(() => { + if (snapshot.lastEndReason) { + const note = friendlyEndReason(snapshot.lastEndReason); + if (note) showToast(note, 'info'); + controller.clearEnded(); + } + }, [snapshot.lastEndReason, controller, showToast]); + + const value = useMemo( + () => ({ + active: snapshot.active, + activeMatchId: snapshot.activeMatchId, + startCall: (args) => controller.startCall(args), + historyNonce: snapshot.historyNonce, + }), + [snapshot.active, snapshot.activeMatchId, snapshot.historyNonce, controller] + ); + + return ( + + {children} + + {/* Incoming-call ring — shows on ANY screen for a signed-in user. */} + {snapshot.incoming && !snapshot.active && ( + void controller.acceptIncoming('VIDEO')} + onAcceptAudio={() => void controller.acceptIncoming('AUDIO')} + onDecline={() => void controller.declineIncoming()} + /> + )} + + {/* In-call surface. */} + {snapshot.active && ( + controller.toggleMute()} + onToggleCamera={() => controller.toggleCamera()} + onStartScreenShare={() => void controller.startScreenShare()} + onStopScreenShare={() => controller.stopScreenShare()} + onAcceptScreenShare={() => controller.acceptScreenShare()} + onDeclineScreenShare={() => controller.declineScreenShare()} + onEnableCaptions={() => void controller.enableCaptions()} + onDisableCaptions={() => controller.disableCaptions()} + onSelectCaptionLanguage={(lang) => void controller.setCaptionLanguage(lang)} + onHangup={() => void controller.hangup()} + /> + )} + + ); +} diff --git a/src/components/call/InCallScreen.tsx b/src/components/call/InCallScreen.tsx index 7b6dce4..ea0e017 100644 --- a/src/components/call/InCallScreen.tsx +++ b/src/components/call/InCallScreen.tsx @@ -1,5 +1,8 @@ import { useEffect, useRef, type ReactNode } from 'react'; import { + Captions, + CaptionsOff, + Languages, Mic, MicOff, MonitorUp, @@ -10,7 +13,38 @@ import { } from 'lucide-react'; import type { CallMedia } from '../../api/calls'; import type { CallSessionState, ScreenShareState } from '../../lib/call-session'; -import { UiElement, trackProps, type UiElementId } from '../../lib/analytics/analytics'; +import type { CaptionUiState } from '../../lib/call-captions'; +import { UiElement, trackProps, trackUi, type UiElementId } from '../../lib/analytics/analytics'; + +/** + * Target languages offered in the in-call caption menu (§12.5 / D12). The viewer's target defaults to + * the device locale; this list lets them change it in-call. The on-device translation engine decides + * whether a pair is actually available — an unsupported pick just shows the original with a note, it + * never reaches a cloud translator. Codes are BCP-47 base subtags. + */ +const CAPTION_LANGUAGES: ReadonlyArray<{ code: string; label: string }> = [ + { code: 'en', label: 'English' }, + { code: 'hi', label: 'हिन्दी (Hindi)' }, + { code: 'bn', label: 'বাংলা (Bengali)' }, + { code: 'ta', label: 'தமிழ் (Tamil)' }, + { code: 'te', label: 'తెలుగు (Telugu)' }, + { code: 'mr', label: 'मराठी (Marathi)' }, + { code: 'gu', label: 'ગુજરાતી (Gujarati)' }, + { code: 'kn', label: 'ಕನ್ನಡ (Kannada)' }, + { code: 'ur', label: 'اردو (Urdu)' }, + { code: 'es', label: 'Español (Spanish)' }, + { code: 'fr', label: 'Français (French)' }, + { code: 'de', label: 'Deutsch (German)' }, + { code: 'pt', label: 'Português (Portuguese)' }, + { code: 'ar', label: 'العربية (Arabic)' }, + { code: 'zh', label: '中文 (Chinese)' }, + { code: 'ja', label: '日本語 (Japanese)' }, +]; + +/** The base (ISO 639) subtag, lowercased, so an `en-US` target still matches the `en` menu option. */ +function baseSubtag(code: string): string { + return (code ?? '').toLowerCase().split('-')[0]; +} /** Attaches a MediaStream to a