@@ -4,19 +4,22 @@ import useVideoCallFlow from '@/bcsc-theme/features/verify/live-call/hooks/useVi
4
4
import { VideoCallFlowState } from '@/bcsc-theme/features/verify/live-call/types/live-call'
5
5
import { BCSCScreens , BCSCVerifyIdentityStackParams } from '@/bcsc-theme/types/navigators'
6
6
import { BCDispatchAction , BCState } from '@/store'
7
- import { Button , ButtonType , testIdWithKey , ThemedText , TOKENS , useServices , useStore , useTheme } from '@bifold/core'
7
+ import { ThemedText , TOKENS , useServices , useStore , useTheme } from '@bifold/core'
8
8
import { CommonActions } from '@react-navigation/native'
9
9
import { StackNavigationProp } from '@react-navigation/stack'
10
10
import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
11
11
import { useTranslation } from 'react-i18next'
12
12
import { StyleSheet , useWindowDimensions , View } from 'react-native'
13
13
import InCallManager from 'react-native-incall-manager'
14
14
import { SafeAreaView } from 'react-native-safe-area-context'
15
- import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
16
- import VolumeManager from 'react-native-volume-manager'
15
+ import { VolumeManager } from 'react-native-volume-manager'
17
16
import { MediaStreamTrack , RTCView } from 'react-native-webrtc'
17
+ import CallErrorView from './components/CallErrorView'
18
18
import CallIconButton from './components/CallIconButton'
19
19
import CallLoadingView from './components/CallLoadingView'
20
+ import CallProcessingView from './components/CallProcessingView'
21
+ import { cropDelayMs } from './constants'
22
+ import { clearIntervalIfExists , clearTimeoutIfExists } from './utils/clearTimeoutIfExists'
20
23
import { formatCallTime } from './utils/formatCallTime'
21
24
22
25
type LiveCallScreenProps = {
@@ -35,17 +38,12 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
35
38
const [ callTimer , setCallTimer ] = useState < string > ( '' )
36
39
const [ systemVolume , setSystemVolume ] = useState < number > ( 1 )
37
40
const timerIntervalRef = useRef < NodeJS . Timeout | null > ( null )
41
+ const cropDelayTimeoutRef = useRef < NodeJS . Timeout | null > ( null )
38
42
const { token } = useApi ( )
39
43
const [ logger ] = useServices ( [ TOKENS . UTIL_LOGGER ] )
40
44
45
+ // check if verified, save token if so, and then navigate accordingly
41
46
const leaveCall = useCallback ( async ( ) => {
42
- if ( timerIntervalRef . current ) {
43
- clearInterval ( timerIntervalRef . current )
44
- timerIntervalRef . current = null
45
- }
46
- setCallStartTime ( null )
47
- setCallTimer ( '' )
48
-
49
47
try {
50
48
if ( ! store . bcsc . deviceCode || ! store . bcsc . userCode ) {
51
49
throw new Error ( 'Missing device or user code' )
@@ -73,6 +71,9 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
73
71
} )
74
72
)
75
73
} catch {
74
+ // TODO (bm): as of Sept 10th 2025, the API throws if the user is not
75
+ // verified even though it isn't truly an error. We should check for
76
+ // this case specifically and only throw if it's some other error
76
77
logger . info ( 'User not verified' )
77
78
navigation . dispatch (
78
79
CommonActions . reset ( {
@@ -87,6 +88,7 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
87
88
}
88
89
} , [ store . bcsc . deviceCode , store . bcsc . userCode , token , dispatch , navigation , logger ] )
89
90
91
+ // we pass the leaveCall function to the hook so it can use it when the other side disconnects as well
90
92
const {
91
93
flowState,
92
94
videoCallError,
@@ -99,18 +101,29 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
99
101
setCallEnded,
100
102
} = useVideoCallFlow ( leaveCall )
101
103
102
- const inCall = useMemo ( ( ) => flowState === VideoCallFlowState . IN_CALL , [ flowState ] )
103
-
104
+ // start crop delay timeout when call starts. the crop delay is to match the
105
+ // current BCSC where the timer doesn't start until after 11 seconds. In
106
+ // future we can use this 11 seconds to crop the bottom part of the remote video that
107
+ // shows the users video for the first ten seconds (we don't want that extra
108
+ // feed of the users video since we are already showing it ourselves)
104
109
useEffect ( ( ) => {
105
110
if ( flowState === VideoCallFlowState . IN_CALL && ! callStartTime ) {
106
- const startTime = Date . now ( )
107
- setCallStartTime ( startTime )
111
+ cropDelayTimeoutRef . current = setTimeout ( ( ) => {
112
+ const startTime = Date . now ( )
113
+ setCallStartTime ( startTime )
114
+ } , cropDelayMs )
108
115
} else if ( flowState !== VideoCallFlowState . IN_CALL && callStartTime ) {
109
116
setCallStartTime ( null )
110
117
setCallTimer ( '' )
111
118
}
119
+
120
+ return ( ) => {
121
+ clearTimeoutIfExists ( cropDelayTimeoutRef )
122
+ }
112
123
} , [ flowState , callStartTime ] )
113
124
125
+ // when call start time is first set, begin updating the user-facing
126
+ // display of the call length
114
127
useEffect ( ( ) => {
115
128
if ( callStartTime ) {
116
129
const updateTimer = ( ) => {
@@ -122,31 +135,16 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
122
135
updateTimer ( )
123
136
124
137
timerIntervalRef . current = setInterval ( updateTimer , 1000 )
125
-
126
- return ( ) => {
127
- if ( timerIntervalRef . current ) {
128
- clearInterval ( timerIntervalRef . current )
129
- timerIntervalRef . current = null
130
- }
131
- }
132
- } else {
133
- setCallTimer ( '' )
134
- if ( timerIntervalRef . current ) {
135
- clearInterval ( timerIntervalRef . current )
136
- timerIntervalRef . current = null
137
- }
138
138
}
139
139
} , [ callStartTime ] )
140
140
141
141
useEffect ( ( ) => {
142
142
return ( ) => {
143
- if ( timerIntervalRef . current ) {
144
- clearInterval ( timerIntervalRef . current )
145
- }
143
+ clearIntervalIfExists ( timerIntervalRef )
146
144
}
147
145
} , [ ] )
148
146
149
- // Monitor device volume changes
147
+ // setup volume detection
150
148
useEffect ( ( ) => {
151
149
const getInitialVolume = async ( ) => {
152
150
try {
@@ -168,26 +166,25 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
168
166
}
169
167
} , [ logger ] )
170
168
169
+ // Determine which banner notice to show in an order of priority
171
170
const banner : { type : 'warning' | 'error' ; title : string } | null = useMemo ( ( ) => {
172
171
if ( isInBackground ) {
173
- return { type : 'warning' , title : t ( 'Unified.VideoCall.VideoWillResume' ) }
172
+ return { type : 'warning' , title : t ( 'Unified.VideoCall.Banners. VideoWillResume' ) }
174
173
}
175
174
if ( videoHidden ) {
176
- return { type : 'error' , title : t ( 'Unified.VideoCall.AgentCantSeeYou' ) }
177
- }
178
- if ( systemVolume === 0 ) {
179
- return { type : 'error' , title : 'Please turn up the volume to hear the agent' }
175
+ return { type : 'error' , title : t ( 'Unified.VideoCall.Banners.AgentCantSeeYou' ) }
180
176
}
181
177
if ( onMute ) {
182
- return { type : 'error' , title : t ( 'Unified.VideoCall.AgentCantHearYou' ) }
178
+ return { type : 'error' , title : t ( 'Unified.VideoCall.Banners. AgentCantHearYou' ) }
183
179
}
184
180
if ( systemVolume < 0.2 ) {
185
- return { type : 'warning' , title : 'Your volume is low, you may need to turn it up to hear the agent' }
181
+ return { type : 'warning' , title : t ( 'Unified.VideoCall.Banners.VolumeLow' ) }
186
182
}
187
183
188
184
return null
189
- } , [ isInBackground , onMute , videoHidden , inCall , systemVolume , t ] )
185
+ } , [ isInBackground , onMute , videoHidden , systemVolume , t ] )
190
186
187
+ // whenever mute choice changes, update the audio tracks accordingly
191
188
useEffect ( ( ) => {
192
189
if ( localStream ) {
193
190
const audioTracks = localStream . getAudioTracks ( )
@@ -197,6 +194,7 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
197
194
}
198
195
} , [ onMute , localStream ] )
199
196
197
+ // whenever hide video choice changes, update the video tracks accordingly
200
198
useEffect ( ( ) => {
201
199
if ( localStream ) {
202
200
const videoTracks = localStream . getVideoTracks ( )
@@ -214,14 +212,15 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
214
212
setVideoHidden ( ( prev ) => ! prev )
215
213
} , [ ] )
216
214
215
+ // kick off the process only once (flow state doesn't go back to idle)
217
216
useEffect ( ( ) => {
218
- // only start call automatically once (flow state doesn't go back to idle)
219
217
if ( flowState === VideoCallFlowState . IDLE ) {
220
218
startVideoCall ( )
221
219
InCallManager . start ( { media : 'video' , auto : true } )
222
220
}
223
221
} , [ flowState , startVideoCall ] )
224
222
223
+ // loading / error user-facing state message
225
224
const stateMessage = useMemo ( ( ) => {
226
225
switch ( flowState ) {
227
226
case VideoCallFlowState . CREATING_SESSION :
@@ -239,50 +238,30 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
239
238
}
240
239
} , [ flowState , videoCallError , t ] )
241
240
241
+ // when the user presses the end call button
242
242
const handleEndCall = useCallback ( async ( ) => {
243
243
try {
244
- await cleanup ( )
244
+ logger . info ( 'User initiated call end' )
245
245
setCallEnded ( )
246
+ await cleanup ( )
246
247
await leaveCall ( )
247
248
} catch ( error ) {
248
249
logger . error ( 'Error while leaving video call' , error as Error )
249
250
}
250
- } , [ cleanup , leaveCall , logger ] )
251
+ } , [ setCallEnded , cleanup , leaveCall , logger ] )
251
252
252
253
if ( flowState === VideoCallFlowState . ERROR ) {
253
254
return (
254
- < SafeAreaView style = { { flex : 1 , backgroundColor : ColorPalette . brand . primaryBackground , padding : Spacing . md } } >
255
- < View style = { { flex : 1 , justifyContent : 'center' , alignItems : 'center' } } >
256
- < Icon name = "alert-circle" size = { 64 } color = { ColorPalette . semantic . error } />
257
- < ThemedText variant = { 'headingTwo' } style = { { marginTop : Spacing . lg , textAlign : 'center' } } >
258
- { t ( 'Unified.VideoCall.ConnectionError' ) }
259
- </ ThemedText >
260
- < ThemedText style = { { marginTop : Spacing . md , textAlign : 'center' } } > { stateMessage } </ ThemedText >
261
- </ View >
262
- < View style = { { gap : Spacing . sm } } >
263
- { videoCallError ?. retryable && (
264
- < Button
265
- buttonType = { ButtonType . Primary }
266
- onPress = { retryConnection }
267
- title = { t ( 'Unified.VideoCall.TryAgain' ) }
268
- accessibilityLabel = { t ( 'Unified.VideoCall.TryAgain' ) }
269
- testID = { testIdWithKey ( 'TryAgain' ) }
270
- />
271
- ) }
272
- < Button
273
- buttonType = { ButtonType . Secondary }
274
- onPress = { ( ) => navigation . goBack ( ) }
275
- title = { t ( 'Unified.VideoCall.GoBack' ) }
276
- accessibilityLabel = { t ( 'Unified.VideoCall.GoBack' ) }
277
- testID = { testIdWithKey ( 'GoBack' ) }
278
- />
279
- </ View >
280
- </ SafeAreaView >
255
+ < CallErrorView
256
+ message = { stateMessage || t ( 'Unified.VideoCall.Errors.GenericError' ) }
257
+ onGoBack = { ( ) => navigation . goBack ( ) }
258
+ onRetry = { videoCallError ?. retryable ? retryConnection : undefined }
259
+ />
281
260
)
282
261
}
283
262
284
263
if ( flowState === VideoCallFlowState . CALL_ENDED ) {
285
- return < CallLoadingView message = { t ( 'Unified.VideoCall.CallStates.CallEnded' ) } />
264
+ return < CallProcessingView message = { t ( 'Unified.VideoCall.CallStates.CallEnded' ) } />
286
265
}
287
266
288
267
if ( flowState !== VideoCallFlowState . IN_CALL ) {
@@ -337,7 +316,7 @@ const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {
337
316
< SafeAreaView edges = { [ 'left' , 'right' ] } style = { { flex : 1 , justifyContent : 'space-between' } } >
338
317
< View style = { styles . upperContainer } >
339
318
< View style = { styles . timeAndLabelContainer } >
340
- < ThemedText > { inCall ? 'Service BC' : 'In Queue' } </ ThemedText >
319
+ < ThemedText > { t ( 'Unified.VideoCall.ServiceBC' ) } </ ThemedText >
341
320
{ callTimer ? < ThemedText > { callTimer } </ ThemedText > : null }
342
321
</ View >
343
322
{ banner ? < BannerSection type = { banner . type } title = { banner . title } dismissible = { false } /> : null }
0 commit comments