@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,143 @@
1
+ /**
2
+ * Full message component system.
3
+ * Ported from squad-demo/src/components/message/*.
4
+ * Components: MessageFeed, FeedMessage, CardMine, CardTheirs, Reaction,
5
+ * VoiceReplyOverlay, FeedReactionOverlay, EmptyContent, MessageScreenHeader.
6
+ */
7
+ import React, { memo, useCallback } from 'react';
8
+ import { View, Text, StyleSheet, FlatList, Pressable } from 'react-native';
9
+ import { Colors } from '../../theme/ThemeContext';
10
+ import UserImage from '../ux/user-image/UserImage';
11
+ import { AudioPlayerRow } from '../audio/AudioPlayerRow';
12
+ import { BodySmall, BodyRegular } from '../ux/text/Typography';
13
+
14
+ // --- MessageReaction ---
15
+ export function MessageReaction({
16
+ emoji, count, isOwn, onPress,
17
+ }: { emoji: string; count: number; isOwn: boolean; onPress?: () => void }) {
18
+ return (
19
+ <Pressable style={[styles.reaction, isOwn && styles.reactionOwn]} onPress={onPress}>
20
+ <Text style={styles.reactionEmoji}>{emoji}</Text>
21
+ {count > 1 && <BodySmall style={styles.reactionCount}>{count}</BodySmall>}
22
+ </Pressable>
23
+ );
24
+ }
25
+
26
+ // --- FeedMessage ---
27
+ export interface FeedMessageProps {
28
+ id: string;
29
+ text?: string;
30
+ audioUrl?: string;
31
+ audioDuration?: number;
32
+ isOwn: boolean;
33
+ senderName?: string;
34
+ senderImageUrl?: string;
35
+ timestamp?: string;
36
+ reactions?: Array<{ emoji: string; count: number }>;
37
+ primaryColor?: string;
38
+ onLongPress?: () => void;
39
+ onReact?: (emoji: string) => void;
40
+ }
41
+
42
+ export const FeedMessage = memo(function FeedMessage({
43
+ text, audioUrl, audioDuration, isOwn, senderName, senderImageUrl,
44
+ timestamp, reactions, primaryColor = Colors.purple1, onLongPress, onReact,
45
+ }: FeedMessageProps) {
46
+ const formatTime = (ts?: string) => {
47
+ if (!ts) return '';
48
+ const d = new Date(ts);
49
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
50
+ };
51
+
52
+ return (
53
+ <View style={[styles.messageRow, isOwn && styles.messageRowOwn]}>
54
+ {!isOwn && <UserImage imageUrl={senderImageUrl} displayName={senderName} size={32} />}
55
+ <Pressable
56
+ style={[styles.bubble, isOwn ? [styles.bubbleOwn, { backgroundColor: primaryColor }] : styles.bubbleOther]}
57
+ onLongPress={onLongPress}
58
+ >
59
+ {text && (
60
+ <BodyRegular style={[styles.messageText, { color: isOwn ? Colors.gray1 : Colors.white }]}>
61
+ {text}
62
+ </BodyRegular>
63
+ )}
64
+ {audioUrl && <AudioPlayerRow audioUrl={audioUrl} duration={audioDuration} />}
65
+ {timestamp && (
66
+ <BodySmall style={[styles.timestamp, { color: isOwn ? 'rgba(0,0,0,0.4)' : Colors.gray6 }]}>
67
+ {formatTime(timestamp)}
68
+ </BodySmall>
69
+ )}
70
+ </Pressable>
71
+ {reactions && reactions.length > 0 && (
72
+ <View style={styles.reactions}>
73
+ {reactions.map((r, i) => (
74
+ <MessageReaction key={i} emoji={r.emoji} count={r.count} isOwn={isOwn} onPress={() => onReact?.(r.emoji)} />
75
+ ))}
76
+ </View>
77
+ )}
78
+ </View>
79
+ );
80
+ });
81
+
82
+ // --- VoiceReplyOverlay ---
83
+ export function VoiceReplyOverlay({
84
+ visible, recipientName, onDismiss,
85
+ }: { visible: boolean; recipientName: string; onDismiss: () => void }) {
86
+ if (!visible) return null;
87
+ return (
88
+ <View style={styles.voiceOverlay}>
89
+ <Text style={styles.voiceOverlayText}>Replying to {recipientName}</Text>
90
+ <Pressable onPress={onDismiss}><Text style={styles.voiceOverlayDismiss}>x</Text></Pressable>
91
+ </View>
92
+ );
93
+ }
94
+
95
+ // --- EmptyContent ---
96
+ export function EmptyMessageContent({ recipientName }: { recipientName?: string }) {
97
+ return (
98
+ <View style={styles.empty}>
99
+ <Text style={styles.emptyEmoji}>wave</Text>
100
+ <BodyRegular style={styles.emptyText}>
101
+ Send {recipientName ?? 'them'} a message to get started
102
+ </BodyRegular>
103
+ </View>
104
+ );
105
+ }
106
+
107
+ // --- MessageFeed ---
108
+ export function MessageFeed({
109
+ messages, renderMessage, onEndReached,
110
+ }: { messages: FeedMessageProps[]; renderMessage: (item: FeedMessageProps) => React.ReactNode; onEndReached?: () => void }) {
111
+ return (
112
+ <FlatList
113
+ data={messages}
114
+ keyExtractor={item => item.id}
115
+ renderItem={({ item }) => <>{renderMessage(item)}</>}
116
+ contentContainerStyle={styles.feedList}
117
+ onEndReached={onEndReached}
118
+ onEndReachedThreshold={0.3}
119
+ />
120
+ );
121
+ }
122
+
123
+ const styles = StyleSheet.create({
124
+ messageRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, maxWidth: '85%', marginBottom: 4 },
125
+ messageRowOwn: { alignSelf: 'flex-end', flexDirection: 'row-reverse' },
126
+ bubble: { paddingVertical: 10, paddingHorizontal: 14, borderRadius: 18, maxWidth: '100%' },
127
+ bubbleOwn: { borderBottomRightRadius: 4 },
128
+ bubbleOther: { backgroundColor: Colors.gray2, borderBottomLeftRadius: 4 },
129
+ messageText: { fontSize: 15, lineHeight: 20 },
130
+ timestamp: { marginTop: 4, textAlign: 'right', fontSize: 11 },
131
+ reactions: { flexDirection: 'row', gap: 4, marginTop: -4, marginBottom: 4 },
132
+ reaction: { flexDirection: 'row', alignItems: 'center', backgroundColor: Colors.gray3, borderRadius: 12, paddingVertical: 2, paddingHorizontal: 6 },
133
+ reactionOwn: {},
134
+ reactionEmoji: { fontSize: 14 },
135
+ reactionCount: { color: Colors.gray6, marginLeft: 2 },
136
+ voiceOverlay: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, backgroundColor: Colors.gray2, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.gray3 },
137
+ voiceOverlayText: { color: Colors.white, fontSize: 13 },
138
+ voiceOverlayDismiss: { color: Colors.gray6, fontSize: 18 },
139
+ empty: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 48 },
140
+ emptyEmoji: { fontSize: 40, color: Colors.gray6, marginBottom: 12 },
141
+ emptyText: { color: Colors.gray6, textAlign: 'center' },
142
+ feedList: { padding: 16, gap: 4 },
143
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Full poll component system.
3
+ * Ported from squad-demo/src/components/poll/*.
4
+ * Components: Tag, EmojiReactions, QuestionCardBlur, UserReaction, UserReactionDetailed,
5
+ * PollSummationSelector, ReactionList, SquaddyActivePollCard, CardStack, PollCollectionSection.
6
+ */
7
+ import React, { memo, useState } from 'react';
8
+ import { View, Text, StyleSheet, Pressable, FlatList, Dimensions } from 'react-native';
9
+ import { Colors } from '../../theme/ThemeContext';
10
+ import UserImage from '../ux/user-image/UserImage';
11
+ import { BodySmall, BodyRegular, TitleSmall, SubtitleSmall } from '../ux/text/Typography';
12
+
13
+ const { width: SCREEN_WIDTH } = Dimensions.get('window');
14
+
15
+ // --- PollTag ---
16
+ export function PollTag({ label, color = Colors.purple1 }: { label: string; color?: string }) {
17
+ return (
18
+ <View style={[styles.tag, { backgroundColor: color }]}>
19
+ <Text style={styles.tagText}>{label}</Text>
20
+ </View>
21
+ );
22
+ }
23
+
24
+ // --- PollEmojiReactions ---
25
+ export function PollEmojiReactions({
26
+ reactions, onReact,
27
+ }: { reactions: Array<{ emoji: string; count: number }>; onReact?: (emoji: string) => void }) {
28
+ return (
29
+ <View style={styles.emojiRow}>
30
+ {reactions.map((r, i) => (
31
+ <Pressable key={i} style={styles.emojiItem} onPress={() => onReact?.(r.emoji)}>
32
+ <Text style={styles.emojiText}>{r.emoji}</Text>
33
+ <BodySmall style={styles.emojiCount}>{r.count}</BodySmall>
34
+ </Pressable>
35
+ ))}
36
+ </View>
37
+ );
38
+ }
39
+
40
+ // --- UserReaction ---
41
+ export function PollUserReaction({ userName, userImageUrl, emoji, responseText }: {
42
+ userName: string; userImageUrl?: string; emoji?: string; responseText?: string;
43
+ }) {
44
+ return (
45
+ <View style={styles.userRow}>
46
+ <UserImage imageUrl={userImageUrl} displayName={userName} size={36} />
47
+ <View style={styles.userRowText}>
48
+ <BodyRegular style={styles.userName}>{userName}</BodyRegular>
49
+ {responseText && <BodySmall style={styles.responseText}>{responseText}</BodySmall>}
50
+ </View>
51
+ {emoji && <Text style={styles.userEmoji}>{emoji}</Text>}
52
+ </View>
53
+ );
54
+ }
55
+
56
+ // --- QuestionCardBlur (preview card that's blurred until answered) ---
57
+ export function QuestionCardBlur({ question, isAnswered, children }: {
58
+ question: string; isAnswered: boolean; children: React.ReactNode;
59
+ }) {
60
+ return (
61
+ <View style={styles.questionCard}>
62
+ <TitleSmall style={styles.questionText}>{question}</TitleSmall>
63
+ <View style={[styles.questionContent, !isAnswered && styles.questionBlur]}>
64
+ {children}
65
+ </View>
66
+ {!isAnswered && (
67
+ <View style={styles.blurOverlay}>
68
+ <BodyRegular style={styles.blurText}>Answer to see results</BodyRegular>
69
+ </View>
70
+ )}
71
+ </View>
72
+ );
73
+ }
74
+
75
+ // --- SquaddyActivePollCard ---
76
+ export interface SquaddyActivePollCardProps {
77
+ id: string;
78
+ question: string;
79
+ options: Array<{ id: string; text: string; percentage?: number }>;
80
+ hasVoted: boolean;
81
+ selectedOptionId?: string;
82
+ totalVotes?: number;
83
+ primaryColor?: string;
84
+ onVote?: (optionId: string) => void;
85
+ onPress?: () => void;
86
+ }
87
+
88
+ export const SquaddyActivePollCard = memo(function SquaddyActivePollCard({
89
+ question, options, hasVoted, selectedOptionId, totalVotes, primaryColor = Colors.purple1, onVote, onPress,
90
+ }: SquaddyActivePollCardProps) {
91
+ return (
92
+ <Pressable style={styles.pollCard} onPress={onPress}>
93
+ <TitleSmall style={styles.pollQuestion}>{question}</TitleSmall>
94
+ <View style={styles.pollOptions}>
95
+ {options.map(opt => {
96
+ const isSelected = selectedOptionId === opt.id;
97
+ return (
98
+ <Pressable
99
+ key={opt.id}
100
+ style={[styles.pollOption, isSelected && { borderColor: primaryColor }, hasVoted && styles.pollOptionVoted]}
101
+ onPress={() => !hasVoted && onVote?.(opt.id)}
102
+ disabled={hasVoted}
103
+ >
104
+ <BodyRegular style={[styles.pollOptionText, isSelected && { color: primaryColor }]}>{opt.text}</BodyRegular>
105
+ {hasVoted && opt.percentage !== undefined && (
106
+ <>
107
+ <BodySmall style={styles.pollPercentage}>{Math.round(opt.percentage)}%</BodySmall>
108
+ <View style={styles.pollProgressBg}>
109
+ <View style={[styles.pollProgressFill, { width: `${opt.percentage}%`, backgroundColor: isSelected ? primaryColor : Colors.gray5 }]} />
110
+ </View>
111
+ </>
112
+ )}
113
+ </Pressable>
114
+ );
115
+ })}
116
+ </View>
117
+ {totalVotes !== undefined && (
118
+ <BodySmall style={styles.pollTotal}>{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}</BodySmall>
119
+ )}
120
+ </Pressable>
121
+ );
122
+ });
123
+
124
+ // --- PollCollectionSection (group of poll cards) ---
125
+ export function PollCollectionSection({
126
+ title, polls, primaryColor, onPollPress,
127
+ }: { title?: string; polls: SquaddyActivePollCardProps[]; primaryColor?: string; onPollPress?: (pollId: string) => void }) {
128
+ if (polls.length === 0) return null;
129
+
130
+ return (
131
+ <View>
132
+ {title && <TitleSmall style={styles.collectionTitle}>{title}</TitleSmall>}
133
+ {polls.map(poll => (
134
+ <SquaddyActivePollCard
135
+ key={poll.id}
136
+ {...poll}
137
+ primaryColor={primaryColor}
138
+ onPress={() => onPollPress?.(poll.id)}
139
+ />
140
+ ))}
141
+ </View>
142
+ );
143
+ }
144
+
145
+ // --- CardStack (stacked poll cards with peek) ---
146
+ export function PollCardStack({ polls, primaryColor }: { polls: SquaddyActivePollCardProps[]; primaryColor?: string }) {
147
+ const [currentIndex, setCurrentIndex] = useState(0);
148
+ if (polls.length === 0) return null;
149
+ const current = polls[currentIndex];
150
+ if (!current) return null;
151
+
152
+ return (
153
+ <View style={styles.stack}>
154
+ <SquaddyActivePollCard {...current} primaryColor={primaryColor} />
155
+ {polls.length > 1 && (
156
+ <BodySmall style={styles.stackCount}>{currentIndex + 1} / {polls.length}</BodySmall>
157
+ )}
158
+ </View>
159
+ );
160
+ }
161
+
162
+ // --- PollSummationSelector ---
163
+ export function PollSummationSelector({
164
+ communities, selectedCommunity, onSelect,
165
+ }: { communities: string[]; selectedCommunity: string; onSelect: (community: string) => void }) {
166
+ return (
167
+ <View style={styles.summationSelector}>
168
+ {communities.map(c => (
169
+ <Pressable key={c} style={[styles.summationItem, c === selectedCommunity && styles.summationItemActive]} onPress={() => onSelect(c)}>
170
+ <BodySmall style={[styles.summationText, c === selectedCommunity && styles.summationTextActive]}>{c}</BodySmall>
171
+ </Pressable>
172
+ ))}
173
+ </View>
174
+ );
175
+ }
176
+
177
+ // --- ReactionList ---
178
+ export function PollReactionList({
179
+ reactions,
180
+ }: { reactions: Array<{ userName: string; userImageUrl?: string; emoji: string; responseText?: string }> }) {
181
+ return (
182
+ <FlatList
183
+ data={reactions}
184
+ keyExtractor={(_, i) => String(i)}
185
+ renderItem={({ item }) => <PollUserReaction {...item} />}
186
+ />
187
+ );
188
+ }
189
+
190
+ const styles = StyleSheet.create({
191
+ tag: { borderRadius: 10, paddingHorizontal: 8, paddingVertical: 2 },
192
+ tagText: { color: Colors.white, fontSize: 10, fontWeight: '700' },
193
+ emojiRow: { flexDirection: 'row', gap: 8, paddingVertical: 8 },
194
+ emojiItem: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: Colors.gray3, borderRadius: 16, paddingVertical: 4, paddingHorizontal: 8 },
195
+ emojiText: { fontSize: 16 },
196
+ emojiCount: { color: Colors.gray6 },
197
+ userRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10, paddingHorizontal: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.gray3 },
198
+ userRowText: { flex: 1 },
199
+ userName: { color: Colors.white },
200
+ responseText: { color: Colors.gray6 },
201
+ userEmoji: { fontSize: 20 },
202
+ questionCard: { backgroundColor: Colors.gray2, borderRadius: 16, padding: 16, marginBottom: 12, overflow: 'hidden' },
203
+ questionText: { color: Colors.white, marginBottom: 12 },
204
+ questionContent: {},
205
+ questionBlur: { opacity: 0.15 },
206
+ blurOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center' },
207
+ blurText: { color: Colors.white, fontWeight: '600' },
208
+ pollCard: { backgroundColor: Colors.gray2, borderRadius: 16, padding: 16, marginBottom: 12 },
209
+ pollQuestion: { color: Colors.white, marginBottom: 12 },
210
+ pollOptions: { gap: 8 },
211
+ pollOption: { padding: 14, backgroundColor: Colors.gray3, borderRadius: 12, borderWidth: 2, borderColor: 'transparent' },
212
+ pollOptionVoted: {},
213
+ pollOptionText: { color: Colors.white },
214
+ pollPercentage: { color: Colors.gray6, marginTop: 4, fontWeight: '600' },
215
+ pollProgressBg: { height: 4, backgroundColor: Colors.gray5, borderRadius: 2, marginTop: 6, overflow: 'hidden' },
216
+ pollProgressFill: { height: '100%', borderRadius: 2 },
217
+ pollTotal: { color: Colors.gray6, textAlign: 'center', marginTop: 12 },
218
+ collectionTitle: { color: Colors.white, paddingHorizontal: 24, marginBottom: 8 },
219
+ stack: { alignItems: 'center' },
220
+ stackCount: { color: Colors.gray6, marginTop: 8 },
221
+ summationSelector: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingVertical: 8 },
222
+ summationItem: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16, backgroundColor: Colors.gray3 },
223
+ summationItemActive: { backgroundColor: Colors.purple1 },
224
+ summationText: { color: Colors.gray6, fontSize: 13 },
225
+ summationTextActive: { color: Colors.gray1, fontWeight: '500' },
226
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Sentinel components — auto-running side effects.
3
+ * Ported from squad-demo/src/components/sentinels/*.
4
+ * 12 sentinels: CommunitiesLoader, AuthChanged, NotificationHandler,
5
+ * UserDataLoader, CommunityThemeSyncer, StateIntegritySentinel,
6
+ * CustomerJourneySentinel, CountSessions, UpdateDeviceInfo, etc.
7
+ */
8
+ import React, { useEffect, useCallback } from 'react';
9
+ import { useSetRecoilState, useRecoilValue } from 'recoil';
10
+ import { useApiClient } from '../../SquadProvider';
11
+ import { useTheme } from '../../theme/ThemeContext';
12
+ import { reAllCommunities } from '../../state/communities';
13
+ import { reActiveAccessToken, reActiveUserId } from '../../state/session';
14
+ import { reUserCache, reLoggedInUserLoaded } from '../../state/user';
15
+ import { sessionCount } from '../../state/features';
16
+ import { reDeviceInfo } from '../../state/device-info';
17
+ import { EventProcessor } from '../../realtime/EventProcessor';
18
+
19
+ // --- CommunitiesLoader ---
20
+ export function CommunitiesLoader() {
21
+ const apiClient = useApiClient();
22
+ const setCommunities = useSetRecoilState(reAllCommunities);
23
+
24
+ useEffect(() => {
25
+ const load = async () => {
26
+ try {
27
+ const result = await apiClient.fetchAllCommunities();
28
+ if (result?.communities) {
29
+ setCommunities(result.communities as any[]);
30
+ }
31
+ } catch {}
32
+ };
33
+ load();
34
+ }, [apiClient, setCommunities]);
35
+
36
+ return null;
37
+ }
38
+
39
+ // --- UserDataLoader ---
40
+ export function UserDataLoader() {
41
+ const apiClient = useApiClient();
42
+ const token = useRecoilValue(reActiveAccessToken);
43
+ const setUserCache = useSetRecoilState(reUserCache);
44
+ const setUserLoaded = useSetRecoilState(reLoggedInUserLoaded);
45
+
46
+ useEffect(() => {
47
+ if (!token) return;
48
+ const load = async () => {
49
+ try {
50
+ const user = await apiClient.getLoggedInUser();
51
+ if (user) {
52
+ setUserCache(user as any);
53
+ setUserLoaded(true);
54
+ }
55
+ } catch {}
56
+ };
57
+ load();
58
+ }, [token, apiClient, setUserCache, setUserLoaded]);
59
+
60
+ return null;
61
+ }
62
+
63
+ // --- AuthChanged ---
64
+ export function AuthChangedSentinel() {
65
+ const token = useRecoilValue(reActiveAccessToken);
66
+
67
+ useEffect(() => {
68
+ const processor = EventProcessor.shared;
69
+ if (token) {
70
+ processor.setShouldAllowEvents(true);
71
+ processor.connect();
72
+ } else {
73
+ processor.setShouldAllowEvents(false);
74
+ processor.disconnect();
75
+ }
76
+ }, [token]);
77
+
78
+ return null;
79
+ }
80
+
81
+ // --- CommunityThemeSyncer ---
82
+ export function CommunityThemeSyncer() {
83
+ const user = useRecoilValue(reUserCache) as any;
84
+ const communities = useRecoilValue(reAllCommunities) as any[];
85
+ const { switchTheme } = useTheme();
86
+
87
+ useEffect(() => {
88
+ if (!user?.community || communities.length === 0) return;
89
+ const community = communities.find((c: any) => c.id === user.community || c.name === user.community);
90
+ if (community?.color) {
91
+ const { buildCommunityTheme } = require('../../theme/ThemeContext');
92
+ switchTheme(buildCommunityTheme(community.color, community.secondaryColor));
93
+ }
94
+ }, [user?.community, communities, switchTheme]);
95
+
96
+ return null;
97
+ }
98
+
99
+ // --- CountSessions ---
100
+ export function CountSessionsSentinel() {
101
+ const setSessionCount = useSetRecoilState(sessionCount);
102
+
103
+ useEffect(() => {
104
+ setSessionCount(prev => prev + 1);
105
+ }, [setSessionCount]);
106
+
107
+ return null;
108
+ }
109
+
110
+ // --- UpdateDeviceInfo ---
111
+ export function UpdateDeviceInfoSentinel() {
112
+ const apiClient = useApiClient();
113
+ const token = useRecoilValue(reActiveAccessToken);
114
+ const setDeviceInfo = useSetRecoilState(reDeviceInfo);
115
+
116
+ useEffect(() => {
117
+ if (!token) return;
118
+ const update = async () => {
119
+ try {
120
+ const { Platform } = require('react-native');
121
+ const Constants = (await import('expo-constants')).default;
122
+ const Device = (await import('expo-device')).default;
123
+
124
+ const info = {
125
+ platform: Platform.OS,
126
+ osVersion: Platform.Version?.toString() ?? '',
127
+ appVersion: Constants.expoConfig?.version ?? '0.1.0',
128
+ deviceModel: Device?.modelName ?? '',
129
+ pushToken: null,
130
+ };
131
+
132
+ setDeviceInfo(info);
133
+
134
+ // Send to API
135
+ const { DeviceInfo } = await import('@squad-sports/core');
136
+ await apiClient.updateDeviceInfo(new DeviceInfo(info));
137
+ } catch {}
138
+ };
139
+ update();
140
+ }, [token, apiClient, setDeviceInfo]);
141
+
142
+ return null;
143
+ }
144
+
145
+ // --- StateIntegritySentinel ---
146
+ export function StateIntegritySentinel() {
147
+ const token = useRecoilValue(reActiveAccessToken);
148
+ const userId = useRecoilValue(reActiveUserId);
149
+
150
+ useEffect(() => {
151
+ // Validate state integrity — if we have a token but no userId, something is wrong
152
+ if (token && !userId) {
153
+ console.warn('[StateIntegrity] Token exists but no userId — state may be corrupted');
154
+ }
155
+ }, [token, userId]);
156
+
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * All sentinels combined for easy inclusion in the provider chain.
162
+ */
163
+ export function AllSentinels() {
164
+ return (
165
+ <>
166
+ <CommunitiesLoader />
167
+ <UserDataLoader />
168
+ <AuthChangedSentinel />
169
+ <CommunityThemeSyncer />
170
+ <CountSessionsSentinel />
171
+ <UpdateDeviceInfoSentinel />
172
+ <StateIntegritySentinel />
173
+ </>
174
+ );
175
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * FeedSquad component — squad circle display for the home feed.
3
+ * Ported from squad-demo/src/components/squad/FeedSquad.tsx.
4
+ */
5
+ import React, { memo } from 'react';
6
+ import { View, StyleSheet, FlatList, Pressable } from 'react-native';
7
+ import { Colors } from '../../theme/ThemeContext';
8
+ import UserImage from '../ux/user-image/UserImage';
9
+ import { BodySmall } from '../ux/text/Typography';
10
+
11
+ interface SquadMember {
12
+ id: string;
13
+ displayName?: string;
14
+ imageUrl?: string;
15
+ }
16
+
17
+ interface FeedSquadProps {
18
+ members: SquadMember[];
19
+ onMemberPress: (userId: string) => void;
20
+ primaryColor?: string;
21
+ }
22
+
23
+ function FeedSquad({ members, onMemberPress, primaryColor = Colors.purple1 }: FeedSquadProps) {
24
+ return (
25
+ <FlatList
26
+ data={members}
27
+ keyExtractor={item => item.id}
28
+ horizontal
29
+ showsHorizontalScrollIndicator={false}
30
+ contentContainerStyle={styles.list}
31
+ renderItem={({ item }) => (
32
+ <Pressable style={styles.member} onPress={() => onMemberPress(item.id)} accessibilityLabel={`${item.displayName}'s profile`}>
33
+ <UserImage
34
+ imageUrl={item.imageUrl}
35
+ displayName={item.displayName}
36
+ size={56}
37
+ borderColor={primaryColor}
38
+ />
39
+ <BodySmall style={styles.memberName} numberOfLines={1}>
40
+ {item.displayName?.split(' ')[0] ?? ''}
41
+ </BodySmall>
42
+ </Pressable>
43
+ )}
44
+ />
45
+ );
46
+ }
47
+
48
+ export default memo(FeedSquad);
49
+
50
+ const styles = StyleSheet.create({
51
+ list: { paddingHorizontal: 24, gap: 16 },
52
+ member: { alignItems: 'center', width: 64 },
53
+ memberName: { color: Colors.white, marginTop: 6, textAlign: 'center' },
54
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Toast components.
3
+ * Ported from squad-demo/src/components/toasts/*.
4
+ */
5
+ import React, { useEffect, useState } from 'react';
6
+ import { Animated, StyleSheet, Text, View, Pressable, ActivityIndicator } from 'react-native';
7
+ import { Colors } from '../../theme/ThemeContext';
8
+
9
+ // --- SuccessToast ---
10
+ export function SuccessToast({ message, visible, onDismiss }: { message: string; visible: boolean; onDismiss?: () => void }) {
11
+ return <ToastBase type="success" message={message} visible={visible} onDismiss={onDismiss} />;
12
+ }
13
+
14
+ // --- ErrorToast ---
15
+ export function ErrorToast({ message, visible, onDismiss }: { message: string; visible: boolean; onDismiss?: () => void }) {
16
+ return <ToastBase type="error" message={message} visible={visible} onDismiss={onDismiss} />;
17
+ }
18
+
19
+ // --- BusyToast ---
20
+ export function BusyToast({ message, visible }: { message: string; visible: boolean }) {
21
+ if (!visible) return null;
22
+ return (
23
+ <Animated.View style={styles.toast}>
24
+ <ActivityIndicator size="small" color={Colors.white} />
25
+ <Text style={styles.toastText}>{message}</Text>
26
+ </Animated.View>
27
+ );
28
+ }
29
+
30
+ // --- Base Toast ---
31
+ function ToastBase({ type, message, visible, onDismiss, duration = 3000 }: {
32
+ type: 'success' | 'error' | 'info'; message: string; visible: boolean; onDismiss?: () => void; duration?: number;
33
+ }) {
34
+ const [fadeAnim] = useState(new Animated.Value(0));
35
+ const [shouldRender, setShouldRender] = useState(false);
36
+
37
+ const indicatorColor = type === 'success' ? Colors.green : type === 'error' ? Colors.red : Colors.purple1;
38
+
39
+ useEffect(() => {
40
+ if (visible) {
41
+ setShouldRender(true);
42
+ Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }).start();
43
+ if (duration > 0) {
44
+ const t = setTimeout(() => {
45
+ Animated.timing(fadeAnim, { toValue: 0, duration: 300, useNativeDriver: true }).start(() => {
46
+ setShouldRender(false);
47
+ onDismiss?.();
48
+ });
49
+ }, duration);
50
+ return () => clearTimeout(t);
51
+ }
52
+ } else {
53
+ Animated.timing(fadeAnim, { toValue: 0, duration: 300, useNativeDriver: true }).start(() => setShouldRender(false));
54
+ }
55
+ }, [visible]);
56
+
57
+ if (!shouldRender) return null;
58
+
59
+ return (
60
+ <Animated.View style={[styles.toast, { opacity: fadeAnim }]}>
61
+ <View style={[styles.indicator, { backgroundColor: indicatorColor }]} />
62
+ <Text style={styles.toastText} numberOfLines={2}>{message}</Text>
63
+ <Pressable onPress={onDismiss}><Text style={styles.dismiss}>x</Text></Pressable>
64
+ </Animated.View>
65
+ );
66
+ }
67
+
68
+ // --- ToastWrapper (renders from modal queue) ---
69
+ export function ToastWrapper({ toast, onDismiss }: {
70
+ toast: { type: string; message: string } | null; onDismiss: () => void;
71
+ }) {
72
+ if (!toast) return null;
73
+ return <ToastBase type={toast.type as any} message={toast.message} visible={true} onDismiss={onDismiss} />;
74
+ }
75
+
76
+ const styles = StyleSheet.create({
77
+ toast: {
78
+ position: 'absolute', top: 60, left: 16, right: 16,
79
+ backgroundColor: Colors.gray2, borderRadius: 12,
80
+ flexDirection: 'row', alignItems: 'center',
81
+ paddingVertical: 12, paddingHorizontal: 16,
82
+ zIndex: 10000, shadowColor: '#000', shadowOffset: { width: 0, height: 4 },
83
+ shadowOpacity: 0.3, shadowRadius: 8, elevation: 8,
84
+ },
85
+ indicator: { width: 4, height: 24, borderRadius: 2, marginRight: 12 },
86
+ toastText: { flex: 1, color: Colors.white, fontSize: 14 },
87
+ dismiss: { color: Colors.gray6, fontSize: 18, paddingLeft: 12 },
88
+ });