@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,175 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ Share,
7
+ Pressable,
8
+ ActivityIndicator,
9
+ } from 'react-native';
10
+
11
+ import { useApiClient } from '../../SquadProvider';
12
+ import { useTheme, Colors } from '../../theme/ThemeContext';
13
+ import ScreenHeader from '../../components/ux/layout/ScreenHeader';
14
+ import Button from '../../components/ux/buttons/Button';
15
+ import { TitleMedium, BodyRegular, BodyMedium, TitleSmall, BodySmall } from '../../components/ux/text/Typography';
16
+
17
+ // Dynamic import for QR code (optional peer dep)
18
+ let QRCode: React.ComponentType<{ value: string; size: number; backgroundColor: string; color: string }> | null = null;
19
+ try {
20
+ QRCode = require('react-native-qrcode-svg').default;
21
+ } catch {
22
+ // react-native-qrcode-svg not installed — will show fallback
23
+ }
24
+
25
+ export function InviteScreen() {
26
+ const apiClient = useApiClient();
27
+ const { theme } = useTheme();
28
+
29
+ const [inviteCode, setInviteCode] = useState<string | null>(null);
30
+ const [loading, setLoading] = useState(true);
31
+ const [copied, setCopied] = useState(false);
32
+
33
+ const inviteUrl = inviteCode
34
+ ? `https://app.withyoursquad.com/invite/${inviteCode}`
35
+ : '';
36
+
37
+ useEffect(() => {
38
+ const getCode = async () => {
39
+ try {
40
+ const result = await apiClient.getInvitationCode();
41
+ setInviteCode(result?.code ?? null);
42
+ } catch {
43
+ // Handle error
44
+ } finally {
45
+ setLoading(false);
46
+ }
47
+ };
48
+ getCode();
49
+ }, [apiClient]);
50
+
51
+ const shareCode = useCallback(async () => {
52
+ if (!inviteCode) return;
53
+ try {
54
+ await Share.share({
55
+ message: `Join my squad on Squad Sports! Use code: ${inviteCode}\n\n${inviteUrl}`,
56
+ });
57
+ } catch {
58
+ // User cancelled share
59
+ }
60
+ }, [inviteCode, inviteUrl]);
61
+
62
+ const copyCode = useCallback(async () => {
63
+ if (!inviteCode) return;
64
+ try {
65
+ const Clipboard = await import('expo-clipboard');
66
+ await Clipboard.setStringAsync(inviteCode);
67
+ setCopied(true);
68
+ setTimeout(() => setCopied(false), 2000);
69
+ } catch {
70
+ // expo-clipboard not installed
71
+ }
72
+ }, [inviteCode]);
73
+
74
+ return (
75
+ <View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
76
+ <ScreenHeader title="Invite" />
77
+
78
+ <View style={styles.content}>
79
+ <View style={styles.codeSection}>
80
+ <TitleMedium style={styles.heading}>Grow Your Squad</TitleMedium>
81
+ <BodyRegular style={styles.subtitle}>
82
+ Share your invite code with friends to add them to your squad
83
+ </BodyRegular>
84
+
85
+ {loading && <ActivityIndicator color={Colors.white} />}
86
+
87
+ {inviteCode && (
88
+ <Pressable style={styles.codeContainer} onPress={copyCode}>
89
+ <TitleSmall style={styles.codeText}>{inviteCode}</TitleSmall>
90
+ <BodyMedium style={[styles.copyHint, copied && { color: Colors.green }]}>
91
+ {copied ? 'Copied!' : 'Tap to copy'}
92
+ </BodyMedium>
93
+ </Pressable>
94
+ )}
95
+ </View>
96
+
97
+ <View style={styles.qrSection}>
98
+ {inviteCode && QRCode ? (
99
+ <View style={styles.qrContainer}>
100
+ <QRCode
101
+ value={inviteUrl}
102
+ size={180}
103
+ backgroundColor={Colors.white}
104
+ color={Colors.gray1}
105
+ />
106
+ </View>
107
+ ) : inviteCode ? (
108
+ // Fallback when QR library not installed — show URL
109
+ <View style={styles.qrFallback}>
110
+ <BodySmall style={styles.qrFallbackLabel}>Invite Link</BodySmall>
111
+ <BodyMedium style={styles.qrFallbackUrl} numberOfLines={2}>
112
+ {inviteUrl}
113
+ </BodyMedium>
114
+ </View>
115
+ ) : null}
116
+ <BodyMedium style={styles.qrHint}>
117
+ Others can scan this code to join your squad
118
+ </BodyMedium>
119
+ </View>
120
+ </View>
121
+
122
+ <View style={styles.footer}>
123
+ <Button
124
+ style={[styles.shareButton, { backgroundColor: theme.buttonColor }]}
125
+ onPress={shareCode}
126
+ disabled={!inviteCode}
127
+ >
128
+ <Text style={[styles.shareButtonText, { color: theme.buttonText }]}>
129
+ Share Invite Link
130
+ </Text>
131
+ </Button>
132
+ </View>
133
+ </View>
134
+ );
135
+ }
136
+
137
+ const styles = StyleSheet.create({
138
+ container: { flex: 1 },
139
+ content: { flex: 1, paddingHorizontal: 24, paddingTop: 16 },
140
+ codeSection: { alignItems: 'center', marginBottom: 32 },
141
+ heading: { color: Colors.white, textAlign: 'center', marginBottom: 8 },
142
+ subtitle: { color: Colors.gray6, textAlign: 'center', marginBottom: 24 },
143
+ codeContainer: {
144
+ backgroundColor: Colors.gray2,
145
+ borderRadius: 12,
146
+ paddingVertical: 20,
147
+ paddingHorizontal: 32,
148
+ alignItems: 'center',
149
+ },
150
+ codeText: { color: Colors.white, letterSpacing: 4, fontSize: 24 },
151
+ copyHint: { color: Colors.gray6, marginTop: 4 },
152
+ qrSection: { alignItems: 'center' },
153
+ qrContainer: {
154
+ padding: 16,
155
+ backgroundColor: Colors.white,
156
+ borderRadius: 16,
157
+ marginBottom: 12,
158
+ },
159
+ qrFallback: {
160
+ backgroundColor: Colors.gray2,
161
+ borderRadius: 12,
162
+ padding: 20,
163
+ width: '100%',
164
+ alignItems: 'center',
165
+ marginBottom: 12,
166
+ },
167
+ qrFallbackLabel: { color: Colors.gray6, marginBottom: 4 },
168
+ qrFallbackUrl: { color: Colors.purple1, textAlign: 'center' },
169
+ qrHint: { color: Colors.gray6, textAlign: 'center' },
170
+ footer: { paddingHorizontal: 24, paddingBottom: 32 },
171
+ shareButton: {
172
+ height: 56, borderRadius: 28, justifyContent: 'center', alignItems: 'center',
173
+ },
174
+ shareButtonText: { fontSize: 16, fontWeight: '600' },
175
+ });
@@ -0,0 +1,360 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ FlatList,
7
+ TextInput,
8
+ Pressable,
9
+ KeyboardAvoidingView,
10
+ Platform,
11
+ Alert,
12
+ } from 'react-native';
13
+ import { useRoute } from '@react-navigation/native';
14
+ import { Audio } from 'expo-av';
15
+ import type { RouteProp } from '@react-navigation/native';
16
+
17
+ import type { RootStackParamList } from '../../navigation/SquadNavigator';
18
+ import { useApiClient } from '../../SquadProvider';
19
+ import { useTheme, Colors } from '../../theme/ThemeContext';
20
+ import { useMessageUpdates } from '../../realtime/useRealtimeSync';
21
+ import { OfflineQueue } from '../../realtime/OfflineQueue';
22
+ import ScreenHeader from '../../components/ux/layout/ScreenHeader';
23
+ import MessageCard from '../../components/message/MessageCard';
24
+ import Button from '../../components/ux/buttons/Button';
25
+ import { BodySmall } from '../../components/ux/text/Typography';
26
+
27
+ type Route = RouteProp<RootStackParamList, 'Messaging'>;
28
+
29
+ interface MessageItem {
30
+ id: string;
31
+ text?: string;
32
+ audioUrl?: string;
33
+ audioDuration?: number;
34
+ senderId: string;
35
+ senderName?: string;
36
+ senderImage?: string;
37
+ createdAt: string;
38
+ isOwn: boolean;
39
+ reactions?: Array<{ emoji: string; count: number }>;
40
+ }
41
+
42
+ export function MessagingScreen() {
43
+ const route = useRoute<Route>();
44
+ const apiClient = useApiClient();
45
+ const { theme } = useTheme();
46
+ const flatListRef = useRef<FlatList>(null);
47
+
48
+ const { connectionId } = route.params;
49
+ const [messages, setMessages] = useState<MessageItem[]>([]);
50
+ const [inputText, setInputText] = useState('');
51
+ const [loading, setLoading] = useState(true);
52
+ const [isRecording, setIsRecording] = useState(false);
53
+ const [recordingDuration, setRecordingDuration] = useState(0);
54
+ const recordingRef = useRef<Audio.Recording | null>(null);
55
+ const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
56
+
57
+ // Load messages
58
+ const loadMessages = useCallback(async () => {
59
+ try {
60
+ const conversation = await apiClient.getMessages(connectionId);
61
+ if (conversation?.messages) {
62
+ const loggedInUser = await apiClient.getLoggedInUser();
63
+ const myId = loggedInUser?.id;
64
+
65
+ const items: MessageItem[] = conversation.messages.map((m: any) => ({
66
+ id: m.id,
67
+ text: m.text,
68
+ audioUrl: m.audioUrl,
69
+ audioDuration: m.audioDuration,
70
+ senderId: m.sender?.id ?? '',
71
+ senderName: m.sender?.displayName,
72
+ senderImage: m.sender?.imageUrl,
73
+ createdAt: m.createdAt ?? '',
74
+ isOwn: m.sender?.id === myId,
75
+ reactions: m.reactions?.map((r: any) => ({ emoji: r.emoji, count: r.count ?? 1 })),
76
+ }));
77
+ setMessages(items);
78
+ }
79
+ } catch (err) {
80
+ console.error('[Messaging] Error loading messages:', err);
81
+ } finally {
82
+ setLoading(false);
83
+ }
84
+ }, [connectionId, apiClient]);
85
+
86
+ useEffect(() => {
87
+ loadMessages();
88
+ }, [loadMessages]);
89
+
90
+ // Real-time message updates
91
+ useMessageUpdates(connectionId, () => {
92
+ loadMessages();
93
+ });
94
+
95
+ // Send text message
96
+ const sendTextMessage = useCallback(async () => {
97
+ if (!inputText.trim()) return;
98
+
99
+ const text = inputText.trim();
100
+ setInputText('');
101
+
102
+ const tempId = `temp-${Date.now()}`;
103
+ setMessages(prev => [
104
+ ...prev,
105
+ {
106
+ id: tempId,
107
+ text,
108
+ senderId: 'me',
109
+ createdAt: new Date().toISOString(),
110
+ isOwn: true,
111
+ },
112
+ ]);
113
+
114
+ try {
115
+ const { Message } = await import('@squad-sports/core');
116
+ const message = new Message({ text, connectionId } as any);
117
+ const created = await apiClient.createMessage(message);
118
+
119
+ if (created?.id) {
120
+ setMessages(prev =>
121
+ prev.map(m => m.id === tempId ? { ...m, id: created.id! } : m),
122
+ );
123
+ }
124
+ } catch {
125
+ // Queue for offline
126
+ const offlineQueue = new OfflineQueue();
127
+ offlineQueue.enqueue('message:create', { text, connectionId });
128
+ // Keep optimistic message but mark it
129
+ }
130
+ }, [inputText, connectionId, apiClient]);
131
+
132
+ // Audio recording
133
+ const startRecording = useCallback(async () => {
134
+ try {
135
+ const { status } = await Audio.requestPermissionsAsync();
136
+ if (status !== 'granted') {
137
+ Alert.alert('Permission needed', 'Microphone access is required to send voice messages.');
138
+ return;
139
+ }
140
+
141
+ await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
142
+ const recording = new Audio.Recording();
143
+ await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
144
+ await recording.startAsync();
145
+ recordingRef.current = recording;
146
+ setIsRecording(true);
147
+ setRecordingDuration(0);
148
+
149
+ timerRef.current = setInterval(() => {
150
+ setRecordingDuration(prev => prev + 1);
151
+ }, 1000);
152
+ } catch {
153
+ Alert.alert('Error', 'Failed to start recording.');
154
+ }
155
+ }, []);
156
+
157
+ const stopAndSendRecording = useCallback(async () => {
158
+ if (timerRef.current) clearInterval(timerRef.current);
159
+ setIsRecording(false);
160
+
161
+ try {
162
+ if (recordingRef.current) {
163
+ await recordingRef.current.stopAndUnloadAsync();
164
+ const uri = recordingRef.current.getURI();
165
+ recordingRef.current = null;
166
+
167
+ if (uri) {
168
+ // Optimistic: show audio message immediately
169
+ const tempId = `temp-audio-${Date.now()}`;
170
+ setMessages(prev => [
171
+ ...prev,
172
+ {
173
+ id: tempId,
174
+ audioUrl: uri,
175
+ audioDuration: recordingDuration * 1000,
176
+ senderId: 'me',
177
+ createdAt: new Date().toISOString(),
178
+ isOwn: true,
179
+ },
180
+ ]);
181
+
182
+ // Upload: create message then upload audio
183
+ const { Message } = await import('@squad-sports/core');
184
+ const message = new Message({ connectionId } as any);
185
+ const created = await apiClient.createMessage(message);
186
+
187
+ if (created?.id) {
188
+ const response = await fetch(uri);
189
+ const blob = await response.blob();
190
+ const buffer = new Uint8Array(await (blob as any).arrayBuffer());
191
+ await apiClient.uploadMessageAudio(created.id, buffer);
192
+
193
+ setMessages(prev =>
194
+ prev.map(m => m.id === tempId ? { ...m, id: created.id! } : m),
195
+ );
196
+ }
197
+ }
198
+ }
199
+ } catch {
200
+ console.error('[Messaging] Error sending audio message');
201
+ }
202
+ }, [connectionId, recordingDuration, apiClient]);
203
+
204
+ const cancelRecording = useCallback(async () => {
205
+ if (timerRef.current) clearInterval(timerRef.current);
206
+ setIsRecording(false);
207
+ setRecordingDuration(0);
208
+
209
+ try {
210
+ if (recordingRef.current) {
211
+ await recordingRef.current.stopAndUnloadAsync();
212
+ recordingRef.current = null;
213
+ }
214
+ } catch {}
215
+ }, []);
216
+
217
+ const formatDuration = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
218
+
219
+ const renderMessage = useCallback(
220
+ ({ item }: { item: MessageItem }) => (
221
+ <MessageCard
222
+ id={item.id}
223
+ text={item.text}
224
+ audioUrl={item.audioUrl}
225
+ audioDuration={item.audioDuration}
226
+ isOwn={item.isOwn}
227
+ senderName={item.senderName}
228
+ senderImageUrl={item.senderImage}
229
+ timestamp={item.createdAt}
230
+ reactions={item.reactions}
231
+ primaryColor={theme.buttonColor}
232
+ />
233
+ ),
234
+ [theme.buttonColor],
235
+ );
236
+
237
+ return (
238
+ <View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
239
+ <ScreenHeader title="Messages" />
240
+
241
+ <KeyboardAvoidingView
242
+ style={styles.keyboardView}
243
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
244
+ keyboardVerticalOffset={90}
245
+ >
246
+ <FlatList
247
+ ref={flatListRef}
248
+ data={messages}
249
+ keyExtractor={item => item.id}
250
+ renderItem={renderMessage}
251
+ contentContainerStyle={styles.messageList}
252
+ onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: false })}
253
+ />
254
+
255
+ {/* Input bar */}
256
+ <View style={styles.inputBar}>
257
+ {isRecording ? (
258
+ // Recording mode
259
+ <View style={styles.recordingBar}>
260
+ <Pressable onPress={cancelRecording} style={styles.cancelRecord}>
261
+ <Text style={styles.cancelRecordText}>x</Text>
262
+ </Pressable>
263
+ <View style={styles.recordingIndicator}>
264
+ <View style={styles.recordingDot} />
265
+ <BodySmall style={styles.recordingTime}>{formatDuration(recordingDuration)}</BodySmall>
266
+ </View>
267
+ <Pressable
268
+ style={[styles.sendRecordButton, { backgroundColor: theme.buttonColor }]}
269
+ onPress={stopAndSendRecording}
270
+ >
271
+ <Text style={[styles.sendIcon, { color: theme.buttonText }]}>{'>'}</Text>
272
+ </Pressable>
273
+ </View>
274
+ ) : (
275
+ // Text mode
276
+ <>
277
+ <Pressable style={styles.micButton} onPress={startRecording}>
278
+ <Text style={styles.micIcon}>{'M'}</Text>
279
+ </Pressable>
280
+ <TextInput
281
+ value={inputText}
282
+ onChangeText={setInputText}
283
+ placeholder="Type a message..."
284
+ placeholderTextColor={Colors.gray6}
285
+ style={styles.input}
286
+ multiline
287
+ maxLength={500}
288
+ returnKeyType="send"
289
+ onSubmitEditing={sendTextMessage}
290
+ />
291
+ <Pressable
292
+ style={[
293
+ styles.sendButton,
294
+ { backgroundColor: inputText.trim() ? theme.buttonColor : Colors.gray3 },
295
+ ]}
296
+ onPress={sendTextMessage}
297
+ disabled={!inputText.trim()}
298
+ >
299
+ <Text style={[styles.sendIcon, { color: inputText.trim() ? theme.buttonText : Colors.gray6 }]}>
300
+ {'>'}
301
+ </Text>
302
+ </Pressable>
303
+ </>
304
+ )}
305
+ </View>
306
+ </KeyboardAvoidingView>
307
+ </View>
308
+ );
309
+ }
310
+
311
+ const styles = StyleSheet.create({
312
+ container: { flex: 1 },
313
+ keyboardView: { flex: 1 },
314
+ messageList: { padding: 16, gap: 4 },
315
+ inputBar: {
316
+ flexDirection: 'row',
317
+ alignItems: 'flex-end',
318
+ padding: 12,
319
+ paddingBottom: 24,
320
+ gap: 8,
321
+ borderTopWidth: StyleSheet.hairlineWidth,
322
+ borderTopColor: Colors.gray3,
323
+ },
324
+ micButton: {
325
+ width: 40, height: 40, borderRadius: 20,
326
+ backgroundColor: Colors.gray2, justifyContent: 'center', alignItems: 'center',
327
+ },
328
+ micIcon: { color: Colors.white, fontSize: 16, fontWeight: '600' },
329
+ input: {
330
+ flex: 1, backgroundColor: Colors.gray2, borderRadius: 20,
331
+ paddingHorizontal: 16, paddingVertical: 10,
332
+ color: Colors.white, fontSize: 15, maxHeight: 100,
333
+ },
334
+ sendButton: {
335
+ width: 40, height: 40, borderRadius: 20,
336
+ justifyContent: 'center', alignItems: 'center',
337
+ },
338
+ sendIcon: { fontSize: 18, fontWeight: '700' },
339
+
340
+ // Recording mode
341
+ recordingBar: {
342
+ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12,
343
+ },
344
+ cancelRecord: {
345
+ width: 40, height: 40, borderRadius: 20,
346
+ backgroundColor: Colors.gray3, justifyContent: 'center', alignItems: 'center',
347
+ },
348
+ cancelRecordText: { color: Colors.white, fontSize: 18, fontWeight: '300' },
349
+ recordingIndicator: {
350
+ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8,
351
+ },
352
+ recordingDot: {
353
+ width: 10, height: 10, borderRadius: 5, backgroundColor: Colors.red,
354
+ },
355
+ recordingTime: { color: Colors.white, fontVariant: ['tabular-nums'] },
356
+ sendRecordButton: {
357
+ width: 40, height: 40, borderRadius: 20,
358
+ justifyContent: 'center', alignItems: 'center',
359
+ },
360
+ });