@ateam-ai-mobile/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * VoiceCallSheet — the full-screen voice call experience.
3
+ *
4
+ * AdaOrb hero + live transcript (with important-data highlighting) + mute
5
+ * control + an offscreen WebView that runs the actual WebRTC session (see
6
+ * useVoiceCall). Requires `voiceUrl` in the initAdas() config.
7
+ */
8
+ import { useEffect, useRef, useMemo } from 'react';
9
+ import {
10
+ View,
11
+ Text,
12
+ StyleSheet,
13
+ Platform,
14
+ Pressable,
15
+ ScrollView,
16
+ } from 'react-native';
17
+ import Svg, { Path, Line } from 'react-native-svg';
18
+ import { WebView } from 'react-native-webview';
19
+ import { ada } from '@ateam-ai-mobile/core';
20
+ import { AdaOrb, SurfaceFullscreen, type AdaOrbState } from '@ateam-ai-mobile/plugin-runtime';
21
+ import useVoiceCall from './useVoiceCall';
22
+
23
+ interface Props {
24
+ visible: boolean;
25
+ onClose: () => void;
26
+ }
27
+
28
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
29
+
30
+ // Detect important data in voice transcripts so the user can verify it visually.
31
+ const HIGHLIGHT_PATTERNS = [
32
+ /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
33
+ /(?:\+?\d[\s\-().]?){7,}\d/g,
34
+ /\$\d[\d,]*(?:\.\d{1,2})?/g,
35
+ /\b\d{1,2}:\d{2}\s?(?:am|pm|AM|PM)?\b/g,
36
+ /\bhttps?:\/\/\S+/g,
37
+ /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g,
38
+ ];
39
+
40
+ function highlightSegments(text: string): Array<{ text: string; important: boolean }> {
41
+ const ranges: Array<[number, number]> = [];
42
+ for (const p of HIGHLIGHT_PATTERNS) {
43
+ p.lastIndex = 0;
44
+ let m;
45
+ while ((m = p.exec(text)) !== null) {
46
+ ranges.push([m.index, m.index + m[0].length]);
47
+ }
48
+ }
49
+ if (!ranges.length) return [{ text, important: false }];
50
+ ranges.sort((a, b) => a[0] - b[0]);
51
+ const merged: Array<[number, number]> = [];
52
+ for (const r of ranges) {
53
+ if (merged.length && r[0] <= merged[merged.length - 1][1]) {
54
+ merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], r[1]);
55
+ } else {
56
+ merged.push([r[0], r[1]]);
57
+ }
58
+ }
59
+ const segments: Array<{ text: string; important: boolean }> = [];
60
+ let cursor = 0;
61
+ for (const [start, end] of merged) {
62
+ if (start > cursor) segments.push({ text: text.slice(cursor, start), important: false });
63
+ segments.push({ text: text.slice(start, end), important: true });
64
+ cursor = end;
65
+ }
66
+ if (cursor < text.length) segments.push({ text: text.slice(cursor), important: false });
67
+ return segments;
68
+ }
69
+
70
+ function HighlightedText({
71
+ text,
72
+ baseStyle,
73
+ importantStyle,
74
+ }: {
75
+ text: string;
76
+ baseStyle: any;
77
+ importantStyle: any;
78
+ }) {
79
+ const segments = useMemo(() => highlightSegments(text), [text]);
80
+ return (
81
+ <Text style={baseStyle}>
82
+ {segments.map((seg, i) => (
83
+ <Text key={i} style={seg.important ? importantStyle : undefined}>
84
+ {seg.text}
85
+ </Text>
86
+ ))}
87
+ </Text>
88
+ );
89
+ }
90
+
91
+ export default function VoiceCallSheet({ visible, onClose }: Props) {
92
+ const {
93
+ callState, aiPartial, userPartial, transcript, error, muted,
94
+ startCall, stopCall, toggleMute, handleWebViewMessage, getWebViewSource, webViewRef,
95
+ injectedTrackHook,
96
+ } = useVoiceCall();
97
+
98
+ const scrollRef = useRef<ScrollView>(null);
99
+
100
+ useEffect(() => {
101
+ if (visible && callState === 'idle') startCall();
102
+ }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
103
+
104
+ useEffect(() => {
105
+ scrollRef.current?.scrollToEnd?.({ animated: true });
106
+ }, [transcript.length, aiPartial, userPartial]);
107
+
108
+ const handleHangUp = () => {
109
+ stopCall();
110
+ onClose();
111
+ };
112
+
113
+ const orbState: AdaOrbState = useMemo(() => {
114
+ if (callState === 'connecting') return 'thinking';
115
+ if (callState === 'error') return 'idle';
116
+ if (aiPartial) return 'speaking';
117
+ if (userPartial) return 'listening';
118
+ return 'idle';
119
+ }, [callState, aiPartial, userPartial]);
120
+
121
+ const statusLabel = useMemo(() => {
122
+ if (callState === 'connecting') return 'Connecting…';
123
+ if (callState === 'error') return error || 'Couldn’t connect';
124
+ if (aiPartial) return 'Ada is speaking';
125
+ if (userPartial) return 'Listening';
126
+ if (callState === 'connected') return 'Go ahead, I’m here.';
127
+ return '';
128
+ }, [callState, aiPartial, userPartial, error]);
129
+
130
+ if (!visible) return null;
131
+
132
+ const visibleEntries = transcript.filter(e => e.role === 'user' || e.role === 'ai');
133
+
134
+ return (
135
+ <SurfaceFullscreen
136
+ visible={visible}
137
+ onClose={handleHangUp}
138
+ status={statusLabel}
139
+ hero={<AdaOrb size={180} state={orbState} />}
140
+ closeIcon="hangup"
141
+ closeLabel="End"
142
+ >
143
+ <ScrollView
144
+ ref={scrollRef}
145
+ style={styles.transcript}
146
+ contentContainerStyle={styles.transcriptContent}
147
+ showsVerticalScrollIndicator={false}
148
+ >
149
+ {callState === 'error' && error ? (
150
+ <View style={styles.errorBox}>
151
+ <Text style={styles.errorText}>{error}</Text>
152
+ </View>
153
+ ) : null}
154
+ {visibleEntries.map((entry, i) => (
155
+ <View key={`${entry.ts}-${i}`} style={styles.entry}>
156
+ <Text style={styles.role}>
157
+ {entry.role === 'user' ? 'YOU' : 'ADA'}
158
+ </Text>
159
+ <HighlightedText
160
+ text={entry.text}
161
+ baseStyle={styles.entryText}
162
+ importantStyle={styles.important}
163
+ />
164
+ </View>
165
+ ))}
166
+ {(userPartial || aiPartial) ? (
167
+ <View style={styles.entry}>
168
+ <Text style={styles.role}>
169
+ {userPartial ? 'YOU' : 'ADA'}
170
+ </Text>
171
+ <HighlightedText
172
+ text={userPartial || aiPartial}
173
+ baseStyle={styles.entryTextLive}
174
+ importantStyle={styles.important}
175
+ />
176
+ </View>
177
+ ) : null}
178
+ </ScrollView>
179
+
180
+ {/* Mute control — floating above transcript, only when call is live */}
181
+ {callState === 'connected' || callState === 'connecting' ? (
182
+ <View style={styles.muteWrap} pointerEvents="box-none">
183
+ <Pressable
184
+ onPress={toggleMute}
185
+ accessibilityRole="button"
186
+ accessibilityLabel={muted ? 'Unmute microphone' : 'Mute microphone'}
187
+ accessibilityState={{ selected: muted }}
188
+ style={[styles.muteBtn, muted && styles.muteBtnActive]}
189
+ hitSlop={12}
190
+ >
191
+ <Svg width={22} height={22} viewBox="0 0 24 24" fill="none" stroke={muted ? ada.paper : ada.ink} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
192
+ <Path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
193
+ <Path d="M19 10v2a7 7 0 0 1-14 0v-2" />
194
+ <Line x1="12" y1="19" x2="12" y2="23" />
195
+ <Line x1="8" y1="23" x2="16" y2="23" />
196
+ {muted ? <Line x1="3" y1="3" x2="21" y2="21" stroke={ada.paper} strokeWidth={2.4} /> : null}
197
+ </Svg>
198
+ </Pressable>
199
+ <Text style={styles.muteLabel}>{muted ? 'Muted' : 'Mute'}</Text>
200
+ </View>
201
+ ) : null}
202
+
203
+ {/* WebView mounted offscreen — runs WebRTC silently */}
204
+ {callState !== 'idle' && (
205
+ <View style={styles.hiddenWebView} pointerEvents="none">
206
+ <WebView
207
+ ref={webViewRef}
208
+ source={getWebViewSource()}
209
+ style={{ flex: 1, backgroundColor: 'transparent' }}
210
+ onMessage={handleWebViewMessage}
211
+ onError={(e: any) => console.log('[VoiceWebView] error:', e.nativeEvent?.description)}
212
+ injectedJavaScriptBeforeContentLoaded={injectedTrackHook}
213
+ mediaPlaybackRequiresUserAction={false}
214
+ allowsInlineMediaPlayback={true}
215
+ mediaCapturePermissionGrantType="grant"
216
+ javaScriptEnabled={true}
217
+ originWhitelist={['*']}
218
+ />
219
+ </View>
220
+ )}
221
+ </SurfaceFullscreen>
222
+ );
223
+ }
224
+
225
+ const styles = StyleSheet.create({
226
+ transcript: {
227
+ flex: 1,
228
+ paddingHorizontal: 28,
229
+ },
230
+ transcriptContent: {
231
+ paddingTop: 8,
232
+ paddingBottom: 16,
233
+ gap: 14,
234
+ },
235
+ entry: {
236
+ flexDirection: 'row',
237
+ gap: 10,
238
+ alignItems: 'flex-start',
239
+ },
240
+ role: {
241
+ fontSize: 10,
242
+ fontFamily: fontFam,
243
+ fontWeight: '700',
244
+ color: ada.inkMuted,
245
+ letterSpacing: 1.4,
246
+ minWidth: 32,
247
+ paddingTop: 3,
248
+ opacity: 0.55,
249
+ },
250
+ entryText: {
251
+ flex: 1,
252
+ fontSize: 14,
253
+ fontFamily: fontFam,
254
+ fontWeight: '400',
255
+ color: ada.inkSoft,
256
+ lineHeight: 20,
257
+ letterSpacing: -0.1,
258
+ opacity: 0.42,
259
+ },
260
+ entryTextLive: {
261
+ flex: 1,
262
+ fontSize: 14,
263
+ fontFamily: fontFam,
264
+ fontWeight: '400',
265
+ color: ada.inkSoft,
266
+ lineHeight: 20,
267
+ letterSpacing: -0.1,
268
+ opacity: 0.7,
269
+ },
270
+ important: {
271
+ fontSize: 17,
272
+ fontFamily: fontFam,
273
+ fontWeight: '700',
274
+ color: ada.ink,
275
+ letterSpacing: -0.1,
276
+ opacity: 1,
277
+ },
278
+ errorBox: {
279
+ backgroundColor: 'rgba(239,68,68,0.12)',
280
+ borderColor: 'rgba(239,68,68,0.45)',
281
+ borderWidth: 1,
282
+ borderRadius: 10,
283
+ paddingVertical: 10,
284
+ paddingHorizontal: 14,
285
+ marginBottom: 12,
286
+ },
287
+ errorText: {
288
+ fontSize: 13,
289
+ fontFamily: fontFam,
290
+ fontWeight: '500',
291
+ color: '#fca5a5',
292
+ lineHeight: 18,
293
+ },
294
+ muteWrap: {
295
+ position: 'absolute',
296
+ bottom: Platform.OS === 'ios' ? 56 : 40,
297
+ left: 28,
298
+ alignItems: 'center',
299
+ gap: 6,
300
+ },
301
+ muteBtn: {
302
+ width: 56,
303
+ height: 56,
304
+ borderRadius: 28,
305
+ backgroundColor: ada.paperSoft,
306
+ borderWidth: 1,
307
+ borderColor: ada.border,
308
+ justifyContent: 'center',
309
+ alignItems: 'center',
310
+ },
311
+ muteBtnActive: {
312
+ backgroundColor: '#C15545',
313
+ borderColor: '#C15545',
314
+ },
315
+ muteLabel: {
316
+ fontSize: 12,
317
+ fontFamily: fontFam,
318
+ fontWeight: '500',
319
+ color: ada.inkMuted,
320
+ letterSpacing: 0.3,
321
+ },
322
+ hiddenWebView: {
323
+ position: 'absolute',
324
+ width: 1,
325
+ height: 1,
326
+ top: -100,
327
+ left: -100,
328
+ opacity: 0,
329
+ },
330
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * useVoiceCall — WebView-based voice hook.
3
+ *
4
+ * The WebView runs WebRTC + OpenAI Realtime internally (iOS WebView supports
5
+ * WebRTC); communication is postMessage (transcript/state events → RN).
6
+ * Requires `voiceUrl` in the initAdas() config — getWebViewSource() throws
7
+ * without it. Identity (token/tenant/actor) comes from the host-owned config.
8
+ */
9
+ import { useState, useRef, useCallback } from 'react';
10
+ import { getVoiceApiUrl, getToken, getTenant, getUser } from '@ateam-ai-mobile/core';
11
+
12
+ export interface TranscriptEntry {
13
+ role: 'user' | 'ai' | 'tool';
14
+ text: string;
15
+ ts: number;
16
+ }
17
+
18
+ export type CallState = 'idle' | 'connecting' | 'connected' | 'error';
19
+
20
+ // Installed BEFORE the voice app loads. Captures every audio track that
21
+ // could possibly be sent to the peer connection so the mute button can flip
22
+ // enabled=false on all of them, regardless of how the voice app obtained or
23
+ // added the track.
24
+ const INJECT_TRACK_HOOK = `
25
+ (function() {
26
+ if (window._adaTrackHookInstalled) return;
27
+ window._adaTrackHookInstalled = true;
28
+ window._allAudioTracks = [];
29
+ window._adaPeerConnections = [];
30
+
31
+ function track(t) {
32
+ if (t && t.kind === 'audio' && window._allAudioTracks.indexOf(t) === -1) {
33
+ window._allAudioTracks.push(t);
34
+ }
35
+ }
36
+
37
+ // 1) Hook getUserMedia — captures mic streams at request time.
38
+ try {
39
+ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
40
+ const origGUM = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
41
+ navigator.mediaDevices.getUserMedia = async function(constraints) {
42
+ const stream = await origGUM(constraints);
43
+ if (constraints && constraints.audio) {
44
+ try { stream.getAudioTracks().forEach(track); } catch (e) {}
45
+ }
46
+ return stream;
47
+ };
48
+ }
49
+ } catch (e) {}
50
+
51
+ // 2) Hook RTCPeerConnection.addTrack — catches whatever the app sends.
52
+ try {
53
+ if (window.RTCPeerConnection) {
54
+ const origPC = window.RTCPeerConnection;
55
+ const origAddTrack = origPC.prototype.addTrack;
56
+ const origAddStream = origPC.prototype.addStream;
57
+ const origAddTransceiver = origPC.prototype.addTransceiver;
58
+
59
+ origPC.prototype.addTrack = function(t) {
60
+ if (t && t.kind === 'audio') track(t);
61
+ return origAddTrack.apply(this, arguments);
62
+ };
63
+ if (origAddStream) {
64
+ origPC.prototype.addStream = function(stream) {
65
+ try { stream.getAudioTracks().forEach(track); } catch (e) {}
66
+ return origAddStream.apply(this, arguments);
67
+ };
68
+ }
69
+ if (origAddTransceiver) {
70
+ origPC.prototype.addTransceiver = function(arg) {
71
+ if (arg && arg.kind === 'audio') {
72
+ // Transceiver created with kind 'audio' — sender track set later.
73
+ } else if (arg && arg.kind === undefined && arg.track && arg.track.kind === 'audio') {
74
+ track(arg.track);
75
+ }
76
+ const tx = origAddTransceiver.apply(this, arguments);
77
+ try {
78
+ if (tx && tx.sender && tx.sender.track && tx.sender.track.kind === 'audio') track(tx.sender.track);
79
+ } catch (e) {}
80
+ return tx;
81
+ };
82
+ }
83
+ }
84
+ } catch (e) {}
85
+
86
+ // 3) Helper exposed for direct calls.
87
+ window._adaSetMuted = function(muted) {
88
+ let n = 0;
89
+ if (Array.isArray(window._allAudioTracks)) {
90
+ window._allAudioTracks.forEach(function(t) {
91
+ try { t.enabled = !muted; n++; } catch (e) {}
92
+ });
93
+ }
94
+ return n;
95
+ };
96
+ })(); true;
97
+ `;
98
+
99
+ const buildMuteScript = (muted: boolean) => `
100
+ (function(muted) {
101
+ if (typeof window.setMuted === 'function') { window.setMuted(muted); }
102
+ let n = 0;
103
+ if (typeof window._adaSetMuted === 'function') {
104
+ n = window._adaSetMuted(muted);
105
+ }
106
+ // Last-resort scan if hook somehow missed.
107
+ if (n === 0 && Array.isArray(window._allAudioTracks)) {
108
+ window._allAudioTracks.forEach(function(t) {
109
+ try { t.enabled = !muted; n++; } catch (e) {}
110
+ });
111
+ }
112
+ console.log('[ada-mute]', muted ? 'MUTED' : 'UNMUTED', 'tracks=', n);
113
+ })(${muted}); true;
114
+ `;
115
+
116
+ export default function useVoiceCall() {
117
+ const [callState, setCallState] = useState<CallState>('idle');
118
+ const [sessionId, setSessionId] = useState<string | null>(null);
119
+ const [error, setError] = useState<string | null>(null);
120
+ const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
121
+ const [aiPartial, setAiPartial] = useState('');
122
+ const [userPartial, setUserPartial] = useState('');
123
+ const [muted, setMuted] = useState(false);
124
+ const webViewRef = useRef<any>(null);
125
+
126
+ /** Handle postMessage events from the WebView */
127
+ const handleWebViewMessage = useCallback((event: any) => {
128
+ try {
129
+ const data = JSON.parse(event.nativeEvent.data);
130
+ console.log('[VoiceWebView] message:', data.type, data.text?.slice?.(0, 50) || data.state || '');
131
+ switch (data.type) {
132
+ case 'callState':
133
+ case 'call-state':
134
+ setCallState(data.state);
135
+ if (data.sessionId) setSessionId(data.sessionId);
136
+ if (data.error) setError(data.error);
137
+ break;
138
+ case 'transcript':
139
+ setTranscript(prev => [...prev, { role: data.role, text: data.text, ts: Date.now() }]);
140
+ break;
141
+ case 'aiPartial':
142
+ setAiPartial(data.text);
143
+ break;
144
+ case 'userPartial':
145
+ setUserPartial(data.text);
146
+ break;
147
+ case 'clearPartials':
148
+ setAiPartial('');
149
+ setUserPartial('');
150
+ break;
151
+ case 'error':
152
+ setError(data.message);
153
+ setCallState('error');
154
+ break;
155
+ }
156
+ } catch {}
157
+ }, []);
158
+
159
+ const startCall = useCallback(() => {
160
+ setCallState('connecting');
161
+ setError(null);
162
+ setTranscript([]);
163
+ setAiPartial('');
164
+ setUserPartial('');
165
+ }, []);
166
+
167
+ const stopCall = useCallback(() => {
168
+ // Tell WebView to hang up
169
+ webViewRef.current?.injectJavaScript?.('window.hangUp && window.hangUp(); true;');
170
+ setCallState('idle');
171
+ setAiPartial('');
172
+ setUserPartial('');
173
+ setMuted(false);
174
+ }, []);
175
+
176
+ const toggleMute = useCallback(() => {
177
+ setMuted(prev => {
178
+ const next = !prev;
179
+ webViewRef.current?.injectJavaScript?.(buildMuteScript(next));
180
+ return next;
181
+ });
182
+ }, []);
183
+
184
+ /** Build the WebView source. Identity comes from the SDK config (host-owned
185
+ * auth) — token, tenant, actorId are passed to the voice mini-app. */
186
+ const getWebViewSource = useCallback(() => {
187
+ const token = getToken();
188
+ const tenant = getTenant();
189
+ const actorId = getUser()?.actorId || '';
190
+ return {
191
+ uri: `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}`,
192
+ };
193
+ }, []);
194
+
195
+ return {
196
+ callState, sessionId, error, transcript, aiPartial, userPartial, muted,
197
+ startCall, stopCall, toggleMute, handleWebViewMessage, getWebViewSource, webViewRef,
198
+ injectedTrackHook: INJECT_TRACK_HOOK,
199
+ };
200
+ }