@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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +46 -0
  4. package/src/SquadExperience.tsx +200 -0
  5. package/src/SquadProvider.tsx +232 -0
  6. package/src/SquadSportsSDK.ts +286 -0
  7. package/src/__tests__/DeepLinkHandler.test.ts +101 -0
  8. package/src/__tests__/ErrorBoundary.test.tsx +161 -0
  9. package/src/__tests__/EventProcessor.test.ts +241 -0
  10. package/src/__tests__/PushNotificationHandler.test.ts +91 -0
  11. package/src/__tests__/SecureStorage.test.ts +62 -0
  12. package/src/__tests__/SquadSportsSDK.test.ts +278 -0
  13. package/src/__tests__/VerificationCooldown.test.ts +153 -0
  14. package/src/components/ErrorBoundary.tsx +129 -0
  15. package/src/components/SOTD/SOTDComponents.tsx +101 -0
  16. package/src/components/audio/AudioPlayerRow.tsx +189 -0
  17. package/src/components/audio/recording/AudioRecording.tsx +232 -0
  18. package/src/components/communities/CommunityComponents.tsx +78 -0
  19. package/src/components/dialogs/AllDialogs.tsx +123 -0
  20. package/src/components/dialogs/ConfirmDialog.tsx +77 -0
  21. package/src/components/dialogs/PermissionDialog.tsx +132 -0
  22. package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
  23. package/src/components/events/EventComponents.tsx +93 -0
  24. package/src/components/feed/ChatBannerCard.tsx +94 -0
  25. package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
  26. package/src/components/feed/FreestyleCard.tsx +119 -0
  27. package/src/components/feed/InterstitialOverlay.tsx +190 -0
  28. package/src/components/feed/PollCard.tsx +158 -0
  29. package/src/components/feed/SponsoredContentCard.tsx +118 -0
  30. package/src/components/freestyle/FreestyleComponents.tsx +148 -0
  31. package/src/components/index.ts +42 -0
  32. package/src/components/message/MessageCard.tsx +166 -0
  33. package/src/components/message/MessageComponents.tsx +143 -0
  34. package/src/components/poll/PollComponents.tsx +226 -0
  35. package/src/components/sentinels/Sentinels.tsx +175 -0
  36. package/src/components/squad/FeedSquad.tsx +54 -0
  37. package/src/components/toasts/Toasts.tsx +88 -0
  38. package/src/components/ux/RootUXComponents.tsx +157 -0
  39. package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
  40. package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
  41. package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
  42. package/src/components/ux/buttons/Button.tsx +37 -0
  43. package/src/components/ux/buttons/InfoButton.tsx +24 -0
  44. package/src/components/ux/buttons/XButton.tsx +27 -0
  45. package/src/components/ux/carousel/Carousel.tsx +134 -0
  46. package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
  47. package/src/components/ux/errors/ErrorHint.tsx +46 -0
  48. package/src/components/ux/inputs/CodeInput.tsx +121 -0
  49. package/src/components/ux/inputs/DatePicker.tsx +76 -0
  50. package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
  51. package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
  52. package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
  53. package/src/components/ux/inputs/TextInput.tsx +58 -0
  54. package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
  55. package/src/components/ux/layout/BlurOverlay.tsx +26 -0
  56. package/src/components/ux/layout/CrossFade.tsx +30 -0
  57. package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
  58. package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
  59. package/src/components/ux/layout/NetworkBanner.tsx +64 -0
  60. package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
  61. package/src/components/ux/layout/RefreshControl.tsx +7 -0
  62. package/src/components/ux/layout/Screen.tsx +31 -0
  63. package/src/components/ux/layout/ScreenHeader.tsx +89 -0
  64. package/src/components/ux/layout/TabBar.tsx +39 -0
  65. package/src/components/ux/layout/Toast.tsx +116 -0
  66. package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
  67. package/src/components/ux/navigation/BackButton.tsx +29 -0
  68. package/src/components/ux/navigation/LinkButton.tsx +21 -0
  69. package/src/components/ux/navigation/UrlButton.tsx +25 -0
  70. package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
  71. package/src/components/ux/shapes/Shapes.tsx +23 -0
  72. package/src/components/ux/text/Typography.tsx +28 -0
  73. package/src/components/ux/user-image/UserImage.tsx +75 -0
  74. package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
  75. package/src/components/wallet/WalletComponents.tsx +116 -0
  76. package/src/contexts/AuthContext.tsx +45 -0
  77. package/src/contexts/EventProcessorContext.tsx +41 -0
  78. package/src/contexts/PlayerQueueContext.tsx +95 -0
  79. package/src/hooks/useAuth.ts +23 -0
  80. package/src/hooks/useDataRefresh.ts +30 -0
  81. package/src/hooks/useEventProcessor.ts +6 -0
  82. package/src/hooks/useImageOptimization.ts +59 -0
  83. package/src/hooks/useOnboardingStepGuard.ts +36 -0
  84. package/src/hooks/usePendingNavigation.ts +26 -0
  85. package/src/hooks/useSquadData.ts +84 -0
  86. package/src/hooks/useUserCreated.ts +25 -0
  87. package/src/hooks/useUserUpdate.ts +25 -0
  88. package/src/hooks/useViewabilityTracker.ts +40 -0
  89. package/src/index.ts +109 -0
  90. package/src/navigation/SquadNavigator.tsx +262 -0
  91. package/src/realtime/DeepLinkHandler.ts +113 -0
  92. package/src/realtime/EventProcessor.ts +313 -0
  93. package/src/realtime/NetworkMonitor.ts +84 -0
  94. package/src/realtime/OfflineQueue.ts +133 -0
  95. package/src/realtime/PushNotificationHandler.ts +125 -0
  96. package/src/realtime/useRealtimeSync.ts +84 -0
  97. package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
  98. package/src/screens/auth/EnterCodeScreen.tsx +253 -0
  99. package/src/screens/auth/EnterEmailScreen.tsx +234 -0
  100. package/src/screens/auth/LandingScreen.tsx +90 -0
  101. package/src/screens/auth/LoginScreen.tsx +126 -0
  102. package/src/screens/events/EventScreen.tsx +163 -0
  103. package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
  104. package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
  105. package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
  106. package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
  107. package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
  108. package/src/screens/home/HomeScreen.tsx +365 -0
  109. package/src/screens/home/slivers/SquadCircle.tsx +77 -0
  110. package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
  111. package/src/screens/invite/InviteScreen.tsx +175 -0
  112. package/src/screens/messaging/MessagingScreen.tsx +360 -0
  113. package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
  114. package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
  115. package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
  116. package/src/screens/polls/PollResponseScreen.tsx +229 -0
  117. package/src/screens/polls/PollSummationScreen.tsx +78 -0
  118. package/src/screens/profile/ProfileScreen.tsx +234 -0
  119. package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
  120. package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
  121. package/src/screens/settings/EditProfileScreen.tsx +154 -0
  122. package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
  123. package/src/screens/settings/SettingsScreen.tsx +194 -0
  124. package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
  125. package/src/screens/wallet/WalletScreen.tsx +174 -0
  126. package/src/services/AuthStateManager.ts +93 -0
  127. package/src/services/NavigationService.ts +40 -0
  128. package/src/services/UserDataManager.ts +59 -0
  129. package/src/services/UserUpdateService.ts +31 -0
  130. package/src/services/VerificationStateManager.ts +41 -0
  131. package/src/squad-line/CallScreen.tsx +158 -0
  132. package/src/squad-line/IncomingCallOverlay.tsx +113 -0
  133. package/src/squad-line/SquadLineClient.ts +327 -0
  134. package/src/squad-line/useSquadLine.ts +80 -0
  135. package/src/state/audio.ts +38 -0
  136. package/src/state/client.ts +26 -0
  137. package/src/state/communities.ts +45 -0
  138. package/src/state/contacts.ts +28 -0
  139. package/src/state/device-info.ts +22 -0
  140. package/src/state/events.ts +16 -0
  141. package/src/state/features.ts +57 -0
  142. package/src/state/index.ts +121 -0
  143. package/src/state/invitations.ts +16 -0
  144. package/src/state/modal-keys.ts +63 -0
  145. package/src/state/modal-queue.ts +104 -0
  146. package/src/state/navigation.ts +34 -0
  147. package/src/state/permissions.ts +43 -0
  148. package/src/state/session.ts +223 -0
  149. package/src/state/squaddie-of-the-day.ts +21 -0
  150. package/src/state/sync/crdt.ts +70 -0
  151. package/src/state/sync/dependable.ts +213 -0
  152. package/src/state/sync/feed-v2.ts +42 -0
  153. package/src/state/sync/messages.ts +44 -0
  154. package/src/state/sync/offline-support.ts +42 -0
  155. package/src/state/sync/polls.ts +37 -0
  156. package/src/state/sync/refresh.ts +24 -0
  157. package/src/state/sync/squad-v2.ts +25 -0
  158. package/src/state/ui.ts +36 -0
  159. package/src/state/user.ts +46 -0
  160. package/src/state/wallet.ts +26 -0
  161. package/src/storage/SecureStorage.ts +77 -0
  162. package/src/theme/ThemeContext.tsx +159 -0
  163. 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
+ }