@squad-sports/react-native 1.3.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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +46 -0
- package/src/SquadExperience.tsx +200 -0
- package/src/SquadProvider.tsx +232 -0
- package/src/SquadSportsSDK.ts +286 -0
- package/src/__tests__/DeepLinkHandler.test.ts +101 -0
- package/src/__tests__/ErrorBoundary.test.tsx +161 -0
- package/src/__tests__/EventProcessor.test.ts +241 -0
- package/src/__tests__/PushNotificationHandler.test.ts +91 -0
- package/src/__tests__/SecureStorage.test.ts +62 -0
- package/src/__tests__/SquadSportsSDK.test.ts +278 -0
- package/src/__tests__/VerificationCooldown.test.ts +153 -0
- package/src/components/ErrorBoundary.tsx +129 -0
- package/src/components/SOTD/SOTDComponents.tsx +101 -0
- package/src/components/audio/AudioPlayerRow.tsx +189 -0
- package/src/components/audio/recording/AudioRecording.tsx +232 -0
- package/src/components/communities/CommunityComponents.tsx +78 -0
- package/src/components/dialogs/AllDialogs.tsx +123 -0
- package/src/components/dialogs/ConfirmDialog.tsx +77 -0
- package/src/components/dialogs/PermissionDialog.tsx +132 -0
- package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
- package/src/components/events/EventComponents.tsx +93 -0
- package/src/components/feed/ChatBannerCard.tsx +94 -0
- package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
- package/src/components/feed/FreestyleCard.tsx +119 -0
- package/src/components/feed/InterstitialOverlay.tsx +190 -0
- package/src/components/feed/PollCard.tsx +158 -0
- package/src/components/feed/SponsoredContentCard.tsx +118 -0
- package/src/components/freestyle/FreestyleComponents.tsx +148 -0
- package/src/components/index.ts +42 -0
- package/src/components/message/MessageCard.tsx +166 -0
- package/src/components/message/MessageComponents.tsx +143 -0
- package/src/components/poll/PollComponents.tsx +226 -0
- package/src/components/sentinels/Sentinels.tsx +175 -0
- package/src/components/squad/FeedSquad.tsx +54 -0
- package/src/components/toasts/Toasts.tsx +88 -0
- package/src/components/ux/RootUXComponents.tsx +157 -0
- package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
- package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
- package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
- package/src/components/ux/buttons/Button.tsx +37 -0
- package/src/components/ux/buttons/InfoButton.tsx +24 -0
- package/src/components/ux/buttons/XButton.tsx +27 -0
- package/src/components/ux/carousel/Carousel.tsx +134 -0
- package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
- package/src/components/ux/errors/ErrorHint.tsx +46 -0
- package/src/components/ux/inputs/CodeInput.tsx +121 -0
- package/src/components/ux/inputs/DatePicker.tsx +76 -0
- package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
- package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
- package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
- package/src/components/ux/inputs/TextInput.tsx +58 -0
- package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
- package/src/components/ux/layout/BlurOverlay.tsx +26 -0
- package/src/components/ux/layout/CrossFade.tsx +30 -0
- package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
- package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
- package/src/components/ux/layout/NetworkBanner.tsx +64 -0
- package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
- package/src/components/ux/layout/RefreshControl.tsx +7 -0
- package/src/components/ux/layout/Screen.tsx +31 -0
- package/src/components/ux/layout/ScreenHeader.tsx +89 -0
- package/src/components/ux/layout/TabBar.tsx +39 -0
- package/src/components/ux/layout/Toast.tsx +116 -0
- package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
- package/src/components/ux/navigation/BackButton.tsx +29 -0
- package/src/components/ux/navigation/LinkButton.tsx +21 -0
- package/src/components/ux/navigation/UrlButton.tsx +25 -0
- package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
- package/src/components/ux/shapes/Shapes.tsx +23 -0
- package/src/components/ux/text/Typography.tsx +28 -0
- package/src/components/ux/user-image/UserImage.tsx +75 -0
- package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
- package/src/components/wallet/WalletComponents.tsx +116 -0
- package/src/contexts/AuthContext.tsx +45 -0
- package/src/contexts/EventProcessorContext.tsx +41 -0
- package/src/contexts/PlayerQueueContext.tsx +95 -0
- package/src/hooks/useAuth.ts +23 -0
- package/src/hooks/useDataRefresh.ts +30 -0
- package/src/hooks/useEventProcessor.ts +6 -0
- package/src/hooks/useImageOptimization.ts +59 -0
- package/src/hooks/useOnboardingStepGuard.ts +36 -0
- package/src/hooks/usePendingNavigation.ts +26 -0
- package/src/hooks/useSquadData.ts +84 -0
- package/src/hooks/useUserCreated.ts +25 -0
- package/src/hooks/useUserUpdate.ts +25 -0
- package/src/hooks/useViewabilityTracker.ts +40 -0
- package/src/index.ts +109 -0
- package/src/navigation/SquadNavigator.tsx +262 -0
- package/src/realtime/DeepLinkHandler.ts +113 -0
- package/src/realtime/EventProcessor.ts +313 -0
- package/src/realtime/NetworkMonitor.ts +84 -0
- package/src/realtime/OfflineQueue.ts +133 -0
- package/src/realtime/PushNotificationHandler.ts +125 -0
- package/src/realtime/useRealtimeSync.ts +84 -0
- package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
- package/src/screens/auth/EnterCodeScreen.tsx +253 -0
- package/src/screens/auth/EnterEmailScreen.tsx +234 -0
- package/src/screens/auth/LandingScreen.tsx +90 -0
- package/src/screens/auth/LoginScreen.tsx +126 -0
- package/src/screens/events/EventScreen.tsx +163 -0
- package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
- package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
- package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
- package/src/screens/home/HomeScreen.tsx +365 -0
- package/src/screens/home/slivers/SquadCircle.tsx +77 -0
- package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
- package/src/screens/invite/InviteScreen.tsx +175 -0
- package/src/screens/messaging/MessagingScreen.tsx +360 -0
- package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
- package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
- package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
- package/src/screens/polls/PollResponseScreen.tsx +229 -0
- package/src/screens/polls/PollSummationScreen.tsx +78 -0
- package/src/screens/profile/ProfileScreen.tsx +234 -0
- package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
- package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
- package/src/screens/settings/EditProfileScreen.tsx +154 -0
- package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
- package/src/screens/settings/SettingsScreen.tsx +194 -0
- package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
- package/src/screens/wallet/WalletScreen.tsx +174 -0
- package/src/services/AuthStateManager.ts +93 -0
- package/src/services/NavigationService.ts +40 -0
- package/src/services/UserDataManager.ts +59 -0
- package/src/services/UserUpdateService.ts +31 -0
- package/src/services/VerificationStateManager.ts +41 -0
- package/src/squad-line/CallScreen.tsx +158 -0
- package/src/squad-line/IncomingCallOverlay.tsx +113 -0
- package/src/squad-line/SquadLineClient.ts +327 -0
- package/src/squad-line/useSquadLine.ts +80 -0
- package/src/state/audio.ts +38 -0
- package/src/state/client.ts +26 -0
- package/src/state/communities.ts +45 -0
- package/src/state/contacts.ts +28 -0
- package/src/state/device-info.ts +22 -0
- package/src/state/events.ts +16 -0
- package/src/state/features.ts +57 -0
- package/src/state/index.ts +121 -0
- package/src/state/invitations.ts +16 -0
- package/src/state/modal-keys.ts +63 -0
- package/src/state/modal-queue.ts +104 -0
- package/src/state/navigation.ts +34 -0
- package/src/state/permissions.ts +43 -0
- package/src/state/session.ts +223 -0
- package/src/state/squaddie-of-the-day.ts +21 -0
- package/src/state/sync/crdt.ts +70 -0
- package/src/state/sync/dependable.ts +213 -0
- package/src/state/sync/feed-v2.ts +42 -0
- package/src/state/sync/messages.ts +44 -0
- package/src/state/sync/offline-support.ts +42 -0
- package/src/state/sync/polls.ts +37 -0
- package/src/state/sync/refresh.ts +24 -0
- package/src/state/sync/squad-v2.ts +25 -0
- package/src/state/ui.ts +36 -0
- package/src/state/user.ts +46 -0
- package/src/state/wallet.ts +26 -0
- package/src/storage/SecureStorage.ts +77 -0
- package/src/theme/ThemeContext.tsx +159 -0
- package/src/types/modules.d.ts +165 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VerificationStateManager — manages verification lock state to prevent
|
|
3
|
+
* duplicate verification attempts.
|
|
4
|
+
* Ported from squad-demo/src/services/VerificationStateManager.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
let verificationInProgress = false;
|
|
8
|
+
let verificationLockHolder: string | null = null;
|
|
9
|
+
|
|
10
|
+
export function isVerificationInProgress(): boolean {
|
|
11
|
+
return verificationInProgress;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function acquireVerificationLock(holder: string): boolean {
|
|
15
|
+
if (verificationInProgress) return false;
|
|
16
|
+
verificationInProgress = true;
|
|
17
|
+
verificationLockHolder = holder;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function releaseVerificationLock(holder: string): void {
|
|
22
|
+
if (verificationLockHolder === holder) {
|
|
23
|
+
verificationInProgress = false;
|
|
24
|
+
verificationLockHolder = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function cleanupVerificationState(): void {
|
|
29
|
+
verificationInProgress = false;
|
|
30
|
+
verificationLockHolder = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const verificationStateManager = {
|
|
34
|
+
isVerificationInProgress,
|
|
35
|
+
acquireVerificationLock,
|
|
36
|
+
releaseVerificationLock,
|
|
37
|
+
cleanupVerificationState,
|
|
38
|
+
setVerificationInProgress: (value: boolean) => {
|
|
39
|
+
verificationInProgress = value;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme, Colors } from '../theme/ThemeContext';
|
|
5
|
+
import { useSquadLine } from './useSquadLine';
|
|
6
|
+
|
|
7
|
+
function formatDuration(ms: number): string {
|
|
8
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
9
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
10
|
+
const seconds = totalSeconds % 60;
|
|
11
|
+
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Active call screen UI with caller info, timer, and controls.
|
|
16
|
+
*/
|
|
17
|
+
export function CallScreen() {
|
|
18
|
+
const { theme } = useTheme();
|
|
19
|
+
const {
|
|
20
|
+
callState,
|
|
21
|
+
currentCall,
|
|
22
|
+
isMuted,
|
|
23
|
+
isSpeakerOn,
|
|
24
|
+
endCall,
|
|
25
|
+
toggleMute,
|
|
26
|
+
toggleSpeaker,
|
|
27
|
+
} = useSquadLine();
|
|
28
|
+
|
|
29
|
+
const [elapsed, setElapsed] = useState(0);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (callState !== 'connected' || !currentCall?.startTime) {
|
|
33
|
+
setElapsed(0);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const interval = setInterval(() => {
|
|
38
|
+
setElapsed(Date.now() - (currentCall.startTime ?? Date.now()));
|
|
39
|
+
}, 1000);
|
|
40
|
+
|
|
41
|
+
return () => clearInterval(interval);
|
|
42
|
+
}, [callState, currentCall?.startTime]);
|
|
43
|
+
|
|
44
|
+
const statusText =
|
|
45
|
+
callState === 'connecting' ? 'Connecting...'
|
|
46
|
+
: callState === 'ringing' ? 'Ringing...'
|
|
47
|
+
: callState === 'connected' ? formatDuration(elapsed)
|
|
48
|
+
: callState === 'disconnected' ? 'Call Ended'
|
|
49
|
+
: '';
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
|
|
53
|
+
<View style={styles.callerInfo}>
|
|
54
|
+
<View style={[styles.avatar, { backgroundColor: theme.buttonColor }]}>
|
|
55
|
+
<Text style={styles.avatarText}>
|
|
56
|
+
{currentCall?.remoteCaller?.displayName?.[0]?.toUpperCase() ?? '?'}
|
|
57
|
+
</Text>
|
|
58
|
+
</View>
|
|
59
|
+
<Text style={styles.callerName}>
|
|
60
|
+
{currentCall?.remoteCaller?.displayName ?? currentCall?.title ?? 'Call'}
|
|
61
|
+
</Text>
|
|
62
|
+
<Text style={styles.status}>{statusText}</Text>
|
|
63
|
+
</View>
|
|
64
|
+
|
|
65
|
+
<View style={styles.controls}>
|
|
66
|
+
<TouchableOpacity
|
|
67
|
+
style={[styles.controlButton, isMuted && styles.controlButtonActive]}
|
|
68
|
+
onPress={toggleMute}
|
|
69
|
+
>
|
|
70
|
+
<Text style={styles.controlIcon}>{isMuted ? 'Unmute' : 'Mute'}</Text>
|
|
71
|
+
</TouchableOpacity>
|
|
72
|
+
|
|
73
|
+
<TouchableOpacity
|
|
74
|
+
style={[styles.endCallButton]}
|
|
75
|
+
onPress={endCall}
|
|
76
|
+
>
|
|
77
|
+
<Text style={styles.endCallIcon}>End</Text>
|
|
78
|
+
</TouchableOpacity>
|
|
79
|
+
|
|
80
|
+
<TouchableOpacity
|
|
81
|
+
style={[styles.controlButton, isSpeakerOn && styles.controlButtonActive]}
|
|
82
|
+
onPress={toggleSpeaker}
|
|
83
|
+
>
|
|
84
|
+
<Text style={styles.controlIcon}>{isSpeakerOn ? 'Ear' : 'Speaker'}</Text>
|
|
85
|
+
</TouchableOpacity>
|
|
86
|
+
</View>
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const styles = StyleSheet.create({
|
|
92
|
+
container: {
|
|
93
|
+
flex: 1,
|
|
94
|
+
justifyContent: 'space-between',
|
|
95
|
+
alignItems: 'center',
|
|
96
|
+
paddingVertical: 80,
|
|
97
|
+
},
|
|
98
|
+
callerInfo: {
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
},
|
|
101
|
+
avatar: {
|
|
102
|
+
width: 96,
|
|
103
|
+
height: 96,
|
|
104
|
+
borderRadius: 48,
|
|
105
|
+
justifyContent: 'center',
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
marginBottom: 16,
|
|
108
|
+
},
|
|
109
|
+
avatarText: {
|
|
110
|
+
color: Colors.white,
|
|
111
|
+
fontSize: 36,
|
|
112
|
+
fontWeight: '700',
|
|
113
|
+
},
|
|
114
|
+
callerName: {
|
|
115
|
+
color: Colors.white,
|
|
116
|
+
fontSize: 24,
|
|
117
|
+
fontWeight: '600',
|
|
118
|
+
marginBottom: 8,
|
|
119
|
+
},
|
|
120
|
+
status: {
|
|
121
|
+
color: Colors.gray6,
|
|
122
|
+
fontSize: 16,
|
|
123
|
+
},
|
|
124
|
+
controls: {
|
|
125
|
+
flexDirection: 'row',
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
gap: 32,
|
|
128
|
+
},
|
|
129
|
+
controlButton: {
|
|
130
|
+
width: 64,
|
|
131
|
+
height: 64,
|
|
132
|
+
borderRadius: 32,
|
|
133
|
+
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
134
|
+
justifyContent: 'center',
|
|
135
|
+
alignItems: 'center',
|
|
136
|
+
},
|
|
137
|
+
controlButtonActive: {
|
|
138
|
+
backgroundColor: 'rgba(255,255,255,0.3)',
|
|
139
|
+
},
|
|
140
|
+
controlIcon: {
|
|
141
|
+
color: Colors.white,
|
|
142
|
+
fontSize: 12,
|
|
143
|
+
fontWeight: '600',
|
|
144
|
+
},
|
|
145
|
+
endCallButton: {
|
|
146
|
+
width: 72,
|
|
147
|
+
height: 72,
|
|
148
|
+
borderRadius: 36,
|
|
149
|
+
backgroundColor: Colors.red,
|
|
150
|
+
justifyContent: 'center',
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
},
|
|
153
|
+
endCallIcon: {
|
|
154
|
+
color: Colors.white,
|
|
155
|
+
fontSize: 14,
|
|
156
|
+
fontWeight: '700',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { Colors } from '../theme/ThemeContext';
|
|
5
|
+
import { useSquadLine } from './useSquadLine';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Full-screen overlay displayed when receiving an incoming Squad Line call.
|
|
9
|
+
* Shows caller info with accept/decline buttons.
|
|
10
|
+
*/
|
|
11
|
+
export function IncomingCallOverlay() {
|
|
12
|
+
const { incomingCall, acceptCall, rejectCall } = useSquadLine();
|
|
13
|
+
|
|
14
|
+
if (!incomingCall) return null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<View style={styles.overlay}>
|
|
18
|
+
<View style={styles.content}>
|
|
19
|
+
<View style={styles.avatar}>
|
|
20
|
+
<Text style={styles.avatarText}>
|
|
21
|
+
{incomingCall.remoteCaller?.displayName?.[0]?.toUpperCase() ?? '?'}
|
|
22
|
+
</Text>
|
|
23
|
+
</View>
|
|
24
|
+
|
|
25
|
+
<Text style={styles.callerName}>
|
|
26
|
+
{incomingCall.remoteCaller?.displayName ?? 'Unknown'}
|
|
27
|
+
</Text>
|
|
28
|
+
<Text style={styles.callTitle}>
|
|
29
|
+
{incomingCall.title}
|
|
30
|
+
</Text>
|
|
31
|
+
<Text style={styles.subtitle}>Squad Line</Text>
|
|
32
|
+
</View>
|
|
33
|
+
|
|
34
|
+
<View style={styles.buttons}>
|
|
35
|
+
<TouchableOpacity style={styles.declineButton} onPress={rejectCall}>
|
|
36
|
+
<Text style={styles.buttonText}>Decline</Text>
|
|
37
|
+
</TouchableOpacity>
|
|
38
|
+
|
|
39
|
+
<TouchableOpacity style={styles.acceptButton} onPress={acceptCall}>
|
|
40
|
+
<Text style={styles.buttonText}>Accept</Text>
|
|
41
|
+
</TouchableOpacity>
|
|
42
|
+
</View>
|
|
43
|
+
</View>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const styles = StyleSheet.create({
|
|
48
|
+
overlay: {
|
|
49
|
+
...StyleSheet.absoluteFillObject,
|
|
50
|
+
backgroundColor: 'rgba(0,0,0,0.95)',
|
|
51
|
+
justifyContent: 'space-between',
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
paddingVertical: 120,
|
|
54
|
+
zIndex: 9999,
|
|
55
|
+
},
|
|
56
|
+
content: {
|
|
57
|
+
alignItems: 'center',
|
|
58
|
+
},
|
|
59
|
+
avatar: {
|
|
60
|
+
width: 96,
|
|
61
|
+
height: 96,
|
|
62
|
+
borderRadius: 48,
|
|
63
|
+
backgroundColor: Colors.purple1,
|
|
64
|
+
justifyContent: 'center',
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
marginBottom: 24,
|
|
67
|
+
},
|
|
68
|
+
avatarText: {
|
|
69
|
+
color: Colors.white,
|
|
70
|
+
fontSize: 36,
|
|
71
|
+
fontWeight: '700',
|
|
72
|
+
},
|
|
73
|
+
callerName: {
|
|
74
|
+
color: Colors.white,
|
|
75
|
+
fontSize: 28,
|
|
76
|
+
fontWeight: '600',
|
|
77
|
+
marginBottom: 4,
|
|
78
|
+
},
|
|
79
|
+
callTitle: {
|
|
80
|
+
color: Colors.gray6,
|
|
81
|
+
fontSize: 16,
|
|
82
|
+
marginBottom: 4,
|
|
83
|
+
},
|
|
84
|
+
subtitle: {
|
|
85
|
+
color: Colors.gray6,
|
|
86
|
+
fontSize: 14,
|
|
87
|
+
},
|
|
88
|
+
buttons: {
|
|
89
|
+
flexDirection: 'row',
|
|
90
|
+
gap: 48,
|
|
91
|
+
},
|
|
92
|
+
declineButton: {
|
|
93
|
+
width: 72,
|
|
94
|
+
height: 72,
|
|
95
|
+
borderRadius: 36,
|
|
96
|
+
backgroundColor: Colors.red,
|
|
97
|
+
justifyContent: 'center',
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
},
|
|
100
|
+
acceptButton: {
|
|
101
|
+
width: 72,
|
|
102
|
+
height: 72,
|
|
103
|
+
borderRadius: 36,
|
|
104
|
+
backgroundColor: Colors.green,
|
|
105
|
+
justifyContent: 'center',
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
},
|
|
108
|
+
buttonText: {
|
|
109
|
+
color: Colors.white,
|
|
110
|
+
fontSize: 12,
|
|
111
|
+
fontWeight: '600',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import EventEmitter from 'eventemitter3';
|
|
2
|
+
import type { SquadApiClient } from '@squad-sports/core';
|
|
3
|
+
|
|
4
|
+
export type CallState = 'idle' | 'connecting' | 'ringing' | 'connected' | 'disconnected' | 'failed';
|
|
5
|
+
|
|
6
|
+
export interface CallInfo {
|
|
7
|
+
connectionId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
remoteCaller?: {
|
|
10
|
+
id: string;
|
|
11
|
+
displayName: string;
|
|
12
|
+
imageUrl?: string;
|
|
13
|
+
};
|
|
14
|
+
startTime?: number;
|
|
15
|
+
duration?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SquadLineEvents {
|
|
19
|
+
callStateChanged: (state: CallState) => void;
|
|
20
|
+
ringing: (info: CallInfo) => void;
|
|
21
|
+
connected: (info: CallInfo) => void;
|
|
22
|
+
disconnected: (info: CallInfo) => void;
|
|
23
|
+
failed: (error: Error) => void;
|
|
24
|
+
incomingCall: (info: CallInfo) => void;
|
|
25
|
+
muteChanged: (muted: boolean) => void;
|
|
26
|
+
speakerChanged: (speaker: boolean) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Squad Line client for voice calling via Twilio Voice SDK.
|
|
31
|
+
* Replaces the old Daily.co-based SquadLine from squad-demo.
|
|
32
|
+
*
|
|
33
|
+
* Call flow:
|
|
34
|
+
* 1. Caller taps connection -> AddCallTitle -> enters title
|
|
35
|
+
* 2. SDK calls POST /v2/connections/:id/create-connections-line-room
|
|
36
|
+
* 3. SDK calls POST /v2/voice/token -> gets Twilio JWT
|
|
37
|
+
* 4. SDK calls TwilioVoice.connect(token, { To: calleeIdentity })
|
|
38
|
+
* 5. Twilio routes to POST /v2/connections/voice-webhook
|
|
39
|
+
* 6. API returns TwiML: <Dial><Client>calleeIdentity</Client></Dial>
|
|
40
|
+
* 7. Callee receives push -> incoming call UI -> accept
|
|
41
|
+
* 8. Voice connected P2P via Twilio
|
|
42
|
+
*/
|
|
43
|
+
export class SquadLineClient extends EventEmitter {
|
|
44
|
+
private static instance: SquadLineClient | null = null;
|
|
45
|
+
|
|
46
|
+
private callState: CallState = 'idle';
|
|
47
|
+
private currentCall: CallInfo | null = null;
|
|
48
|
+
private isMuted = false;
|
|
49
|
+
private isSpeakerOn = false;
|
|
50
|
+
private twilioVoice: unknown = null; // @twilio/voice-react-native-sdk instance
|
|
51
|
+
private activeCall: unknown = null; // Twilio Call instance
|
|
52
|
+
|
|
53
|
+
private constructor(private apiClient: SquadApiClient) {
|
|
54
|
+
super();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static getInstance(apiClient: SquadApiClient): SquadLineClient {
|
|
58
|
+
if (!SquadLineClient.instance) {
|
|
59
|
+
SquadLineClient.instance = new SquadLineClient(apiClient);
|
|
60
|
+
}
|
|
61
|
+
return SquadLineClient.instance;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static reset(): void {
|
|
65
|
+
SquadLineClient.instance?.endCall();
|
|
66
|
+
SquadLineClient.instance = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Register for incoming calls. Call this after authentication.
|
|
71
|
+
*/
|
|
72
|
+
async register(): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
// Get voice token from API
|
|
75
|
+
const token = await this.apiClient.getVoiceToken();
|
|
76
|
+
if (!token) {
|
|
77
|
+
throw new Error('Failed to get voice token');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Dynamically import Twilio SDK (optional peer dep)
|
|
81
|
+
try {
|
|
82
|
+
const { Voice } = await import('@twilio/voice-react-native-sdk');
|
|
83
|
+
this.twilioVoice = new Voice();
|
|
84
|
+
|
|
85
|
+
// Register for incoming calls
|
|
86
|
+
await (this.twilioVoice as any).register(token);
|
|
87
|
+
|
|
88
|
+
// Listen for incoming call invites
|
|
89
|
+
(this.twilioVoice as any).on('callInvite', (callInvite: any) => {
|
|
90
|
+
this.handleIncomingCall(callInvite);
|
|
91
|
+
});
|
|
92
|
+
} catch (importError) {
|
|
93
|
+
console.warn(
|
|
94
|
+
'[SquadLine] @twilio/voice-react-native-sdk not available. Voice calling disabled.',
|
|
95
|
+
importError,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error('[SquadLine] Registration failed:', error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Make an outgoing call to a squad connection.
|
|
105
|
+
*/
|
|
106
|
+
async makeCall(connectionId: string, title: string, calleeIdentity?: string): Promise<boolean> {
|
|
107
|
+
if (this.callState !== 'idle') {
|
|
108
|
+
console.warn('[SquadLine] Already in a call');
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.setCallState('connecting');
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Step 1: Create line room via API
|
|
116
|
+
await this.apiClient.createConnectionsLineRoom(connectionId);
|
|
117
|
+
|
|
118
|
+
// Step 2: Get voice token
|
|
119
|
+
const token = await this.apiClient.getVoiceToken();
|
|
120
|
+
if (!token) {
|
|
121
|
+
throw new Error('Failed to get voice token');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.currentCall = { connectionId, title };
|
|
125
|
+
|
|
126
|
+
// Step 3: Connect via Twilio
|
|
127
|
+
if (this.twilioVoice) {
|
|
128
|
+
const connectParams: Record<string, string> = {};
|
|
129
|
+
if (calleeIdentity) {
|
|
130
|
+
connectParams.To = calleeIdentity;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.activeCall = await (this.twilioVoice as any).connect(token, {
|
|
134
|
+
params: connectParams,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Bind call event listeners
|
|
138
|
+
this.bindCallEvents(this.activeCall);
|
|
139
|
+
this.setCallState('ringing');
|
|
140
|
+
return true;
|
|
141
|
+
} else {
|
|
142
|
+
throw new Error('Twilio Voice SDK not initialized');
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.setCallState('failed');
|
|
146
|
+
this.emit('failed', error instanceof Error ? error : new Error(String(error)));
|
|
147
|
+
this.cleanup();
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Handle an incoming call invite from Twilio.
|
|
154
|
+
*/
|
|
155
|
+
private handleIncomingCall(callInvite: any): void {
|
|
156
|
+
const info: CallInfo = {
|
|
157
|
+
connectionId: callInvite.customParameters?.connectionId ?? '',
|
|
158
|
+
title: callInvite.customParameters?.title ?? 'Incoming Call',
|
|
159
|
+
remoteCaller: {
|
|
160
|
+
id: callInvite.from ?? '',
|
|
161
|
+
displayName: callInvite.customParameters?.callerName ?? 'Unknown',
|
|
162
|
+
imageUrl: callInvite.customParameters?.callerImage,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
this.currentCall = info;
|
|
167
|
+
this.emit('incomingCall', info);
|
|
168
|
+
|
|
169
|
+
// Store invite for accept/reject
|
|
170
|
+
(this as any)._pendingInvite = callInvite;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Accept an incoming call.
|
|
175
|
+
*/
|
|
176
|
+
async acceptCall(): Promise<void> {
|
|
177
|
+
const invite = (this as any)._pendingInvite;
|
|
178
|
+
if (!invite) {
|
|
179
|
+
console.warn('[SquadLine] No pending call invite');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
this.activeCall = await invite.accept();
|
|
185
|
+
this.bindCallEvents(this.activeCall);
|
|
186
|
+
this.setCallState('connected');
|
|
187
|
+
if (this.currentCall) {
|
|
188
|
+
this.currentCall.startTime = Date.now();
|
|
189
|
+
this.emit('connected', this.currentCall);
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.setCallState('failed');
|
|
193
|
+
this.emit('failed', error instanceof Error ? error : new Error(String(error)));
|
|
194
|
+
this.cleanup();
|
|
195
|
+
} finally {
|
|
196
|
+
(this as any)._pendingInvite = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Reject an incoming call.
|
|
202
|
+
*/
|
|
203
|
+
rejectCall(): void {
|
|
204
|
+
const invite = (this as any)._pendingInvite;
|
|
205
|
+
if (invite) {
|
|
206
|
+
invite.reject();
|
|
207
|
+
(this as any)._pendingInvite = null;
|
|
208
|
+
}
|
|
209
|
+
this.cleanup();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* End the current call.
|
|
214
|
+
*/
|
|
215
|
+
endCall(): void {
|
|
216
|
+
if (this.activeCall) {
|
|
217
|
+
(this.activeCall as any).disconnect();
|
|
218
|
+
}
|
|
219
|
+
this.cleanup();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Toggle mute state.
|
|
224
|
+
*/
|
|
225
|
+
toggleMute(): boolean {
|
|
226
|
+
this.isMuted = !this.isMuted;
|
|
227
|
+
if (this.activeCall) {
|
|
228
|
+
(this.activeCall as any).mute(this.isMuted);
|
|
229
|
+
}
|
|
230
|
+
this.emit('muteChanged', this.isMuted);
|
|
231
|
+
return this.isMuted;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Toggle speaker state.
|
|
236
|
+
*/
|
|
237
|
+
toggleSpeaker(): boolean {
|
|
238
|
+
this.isSpeakerOn = !this.isSpeakerOn;
|
|
239
|
+
// Speaker toggling is handled at the native audio level
|
|
240
|
+
this.emit('speakerChanged', this.isSpeakerOn);
|
|
241
|
+
return this.isSpeakerOn;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Send a reaction during a call.
|
|
246
|
+
*/
|
|
247
|
+
async sendReaction(emoji: string, imageUrl?: string): Promise<void> {
|
|
248
|
+
if (!this.currentCall) return;
|
|
249
|
+
|
|
250
|
+
const callId = (this.activeCall as any)?.getSid?.() ?? '';
|
|
251
|
+
await this.apiClient.createConnectionsLineReaction(
|
|
252
|
+
callId,
|
|
253
|
+
this.currentCall.connectionId,
|
|
254
|
+
emoji,
|
|
255
|
+
imageUrl,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- Getters ---
|
|
260
|
+
|
|
261
|
+
getCallState(): CallState {
|
|
262
|
+
return this.callState;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
getCurrentCall(): CallInfo | null {
|
|
266
|
+
return this.currentCall;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getMuted(): boolean {
|
|
270
|
+
return this.isMuted;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
getSpeakerOn(): boolean {
|
|
274
|
+
return this.isSpeakerOn;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- Private ---
|
|
278
|
+
|
|
279
|
+
private setCallState(state: CallState): void {
|
|
280
|
+
this.callState = state;
|
|
281
|
+
this.emit('callStateChanged', state);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private bindCallEvents(call: any): void {
|
|
285
|
+
if (!call) return;
|
|
286
|
+
|
|
287
|
+
call.on('ringing', () => {
|
|
288
|
+
this.setCallState('ringing');
|
|
289
|
+
if (this.currentCall) this.emit('ringing', this.currentCall);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
call.on('connected', () => {
|
|
293
|
+
this.setCallState('connected');
|
|
294
|
+
if (this.currentCall) {
|
|
295
|
+
this.currentCall.startTime = Date.now();
|
|
296
|
+
this.emit('connected', this.currentCall);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
call.on('reconnecting', () => {
|
|
301
|
+
console.log('[SquadLine] Call reconnecting...');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
call.on('disconnected', () => {
|
|
305
|
+
if (this.currentCall?.startTime) {
|
|
306
|
+
this.currentCall.duration = Date.now() - this.currentCall.startTime;
|
|
307
|
+
}
|
|
308
|
+
this.setCallState('disconnected');
|
|
309
|
+
if (this.currentCall) this.emit('disconnected', this.currentCall);
|
|
310
|
+
this.cleanup();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
call.on('connectFailure', (error: any) => {
|
|
314
|
+
this.setCallState('failed');
|
|
315
|
+
this.emit('failed', new Error(error?.message ?? 'Call connection failed'));
|
|
316
|
+
this.cleanup();
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private cleanup(): void {
|
|
321
|
+
this.activeCall = null;
|
|
322
|
+
this.currentCall = null;
|
|
323
|
+
this.isMuted = false;
|
|
324
|
+
this.isSpeakerOn = false;
|
|
325
|
+
this.callState = 'idle';
|
|
326
|
+
}
|
|
327
|
+
}
|