@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,43 @@
1
+ /**
2
+ * Device permissions atoms.
3
+ * Ported from squad-demo/src/atoms/permissions.ts.
4
+ */
5
+ import { atom, DefaultValue, selector } from 'recoil';
6
+
7
+ export type PermissionStatus = 'granted' | 'denied' | 'undetermined' | 'limited';
8
+
9
+ type AllPermissionsState = {
10
+ microphone: PermissionStatus | null;
11
+ contacts: PermissionStatus | null;
12
+ camera: PermissionStatus | null;
13
+ images: PermissionStatus | null;
14
+ notifications: PermissionStatus | null;
15
+ };
16
+
17
+ const permissionsAtom = atom<AllPermissionsState>({
18
+ key: 'squad-sdk:permissions',
19
+ default: {
20
+ microphone: null,
21
+ contacts: null,
22
+ camera: null,
23
+ images: null,
24
+ notifications: null,
25
+ },
26
+ });
27
+
28
+ function makePermissionSelector(field: keyof AllPermissionsState) {
29
+ return selector<PermissionStatus | null>({
30
+ key: `squad-sdk:permissions:${field}`,
31
+ get: ({ get }) => get(permissionsAtom)[field],
32
+ set: ({ get, set }, newValue) => {
33
+ if (newValue instanceof DefaultValue) return;
34
+ set(permissionsAtom, { ...get(permissionsAtom), [field]: newValue });
35
+ },
36
+ });
37
+ }
38
+
39
+ export const microphonePermissions = makePermissionSelector('microphone');
40
+ export const contactsPermissions = makePermissionSelector('contacts');
41
+ export const cameraPermissions = makePermissionSelector('camera');
42
+ export const imagesPermissions = makePermissionSelector('images');
43
+ export const notificationsPermissions = makePermissionSelector('notifications');
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Auth/Session atoms.
3
+ * Ported from squad-demo/src/atoms/session.ts.
4
+ *
5
+ * Uses SecureStorageAdapter for all auth key persistence so that
6
+ * sensitive values (tokens, user IDs, email, phone) are stored in
7
+ * encrypted storage (expo-secure-store) rather than plain AsyncStorage.
8
+ */
9
+ import { atom, type AtomEffect } from 'recoil';
10
+ import { SecureStorageAdapter } from '../storage/SecureStorage';
11
+
12
+ // --- Shared secure storage instance for Recoil effects ---
13
+ const secureStorage = new SecureStorageAdapter();
14
+
15
+ // --- Storage hydration tracking ---
16
+
17
+ const storageHydrationComplete = new Map<string, boolean>();
18
+ const storageLoadingPromises = new Map<string, Promise<string | null>>();
19
+ const CRITICAL_STORAGE_KEYS = ['SQUAD_SDK_AUTH_TOKEN', 'SQUAD_SDK_AUTH_USER_ID'];
20
+
21
+ function markStorageKeyLoaded(key: string) {
22
+ storageHydrationComplete.set(key, true);
23
+ }
24
+
25
+ /**
26
+ * Secure storage persistence effect.
27
+ * Uses SecureStorageAdapter which routes sensitive keys through
28
+ * expo-secure-store and non-sensitive keys through AsyncStorage.
29
+ */
30
+ function secureStorageEffect(storageKey: string): AtomEffect<string | null> {
31
+ return ({ setSelf, onSet, trigger }) => {
32
+ let isInitialLoad = true;
33
+
34
+ if (trigger === 'get') {
35
+ // Dedup concurrent loads
36
+ if (storageLoadingPromises.has(storageKey)) {
37
+ storageLoadingPromises.get(storageKey)!.then(val => {
38
+ setSelf(val);
39
+ isInitialLoad = false;
40
+ markStorageKeyLoaded(storageKey);
41
+ });
42
+ return;
43
+ }
44
+
45
+ const loadingPromise = secureStorage.getItem(storageKey).catch(() => null);
46
+
47
+ storageLoadingPromises.set(storageKey, loadingPromise);
48
+
49
+ loadingPromise.then(val => {
50
+ setSelf(val);
51
+ storageLoadingPromises.delete(storageKey);
52
+ markStorageKeyLoaded(storageKey);
53
+ isInitialLoad = false;
54
+ });
55
+ }
56
+
57
+ onSet(async newValue => {
58
+ try {
59
+ if (!newValue && !isInitialLoad) {
60
+ await secureStorage.removeItem(storageKey);
61
+ } else if (newValue) {
62
+ await secureStorage.setItem(storageKey, newValue);
63
+ }
64
+ } catch {}
65
+ setSelf(newValue);
66
+ });
67
+ };
68
+ }
69
+
70
+ function secureStorageObjectEffect<T>(storageKey: string): AtomEffect<T> {
71
+ return ({ setSelf, onSet, trigger }) => {
72
+ let isInitialLoad = true;
73
+
74
+ if (trigger === 'get') {
75
+ (async () => {
76
+ try {
77
+ const val = await secureStorage.getItem(storageKey);
78
+ if (val) {
79
+ const parsed = JSON.parse(val);
80
+ if (parsed && typeof parsed === 'object') {
81
+ setSelf(parsed);
82
+ }
83
+ }
84
+ } catch {}
85
+ isInitialLoad = false;
86
+ })();
87
+ }
88
+
89
+ onSet(async newValue => {
90
+ if (!isInitialLoad && newValue && typeof newValue === 'object') {
91
+ try {
92
+ await secureStorage.setItem(storageKey, JSON.stringify(newValue));
93
+ } catch {}
94
+ }
95
+ setSelf(newValue);
96
+ });
97
+ };
98
+ }
99
+
100
+ function secureStorageArrayEffect<T>(storageKey: string): AtomEffect<T[]> {
101
+ return ({ setSelf, onSet, trigger }) => {
102
+ let isInitialLoad = true;
103
+
104
+ if (trigger === 'get') {
105
+ (async () => {
106
+ try {
107
+ const val = await secureStorage.getItem(storageKey);
108
+ if (val) setSelf(JSON.parse(val));
109
+ } catch {}
110
+ isInitialLoad = false;
111
+ })();
112
+ }
113
+
114
+ onSet(async newValue => {
115
+ try {
116
+ if ((!newValue || (newValue as T[]).length === 0) && !isInitialLoad) {
117
+ await secureStorage.removeItem(storageKey);
118
+ } else if (newValue) {
119
+ await secureStorage.setItem(storageKey, JSON.stringify(newValue));
120
+ }
121
+ } catch {}
122
+ setSelf(newValue);
123
+ });
124
+ };
125
+ }
126
+
127
+ // --- Session atoms ---
128
+
129
+ export const reActivePhoneNumber = atom<string | null>({
130
+ key: 'squad-sdk:auth:phone',
131
+ default: null,
132
+ effects: [secureStorageEffect('SQUAD_SDK_AUTH_PHONE')],
133
+ });
134
+
135
+ export const reActiveEmail = atom<string | null>({
136
+ key: 'squad-sdk:auth:email',
137
+ default: null,
138
+ effects: [secureStorageEffect('SQUAD_SDK_AUTH_EMAIL')],
139
+ });
140
+
141
+ export const reActiveCode = atom<string | null>({
142
+ key: 'squad-sdk:auth:code',
143
+ default: null,
144
+ });
145
+
146
+ export const reActiveAccessToken = atom<string | null>({
147
+ key: 'squad-sdk:session:activeAccessToken',
148
+ default: null,
149
+ effects: [secureStorageEffect('SQUAD_SDK_AUTH_TOKEN')],
150
+ });
151
+
152
+ export const reActiveUserId = atom<string | null>({
153
+ key: 'squad-sdk:session:activeUserId',
154
+ default: null,
155
+ effects: [secureStorageEffect('SQUAD_SDK_AUTH_USER_ID')],
156
+ });
157
+
158
+ export const reUserRefresh = atom<number>({
159
+ key: 'squad-sdk:session:userRefresh',
160
+ default: 0,
161
+ });
162
+
163
+ export const reAuthHydrated = atom<boolean>({
164
+ key: 'squad-sdk:auth:hydrated',
165
+ default: false,
166
+ effects: [
167
+ ({ setSelf, trigger }) => {
168
+ if (trigger === 'get') {
169
+ const check = async () => {
170
+ await new Promise<void>(r => setTimeout(() => r(), 50));
171
+ const maxWait = 2000;
172
+ const start = Date.now();
173
+ while (Date.now() - start < maxWait) {
174
+ if (CRITICAL_STORAGE_KEYS.every(k => storageHydrationComplete.get(k))) {
175
+ setSelf(true);
176
+ return;
177
+ }
178
+ await new Promise<void>(r => setTimeout(() => r(), 50));
179
+ }
180
+ setSelf(true); // Timeout — proceed anyway
181
+ };
182
+ check();
183
+ }
184
+ },
185
+ ],
186
+ });
187
+
188
+ export const reActiveCommunityId = atom<string | null>({
189
+ key: 'squad-sdk:session:activeCommunityId',
190
+ default: null,
191
+ effects: [secureStorageEffect('SQUAD_SDK_AUTH_COMMUNITY_ID')],
192
+ });
193
+
194
+ export const reActivePartnerId = atom<string | null>({
195
+ key: 'squad-sdk:session:activePartnerId',
196
+ default: null,
197
+ effects: [secureStorageEffect('SQUAD_SDK_AUTH_PARTNER_ID')],
198
+ });
199
+
200
+ export const rePendingNavigation = atom<{
201
+ hasPendingInviter: boolean;
202
+ inviterId?: string;
203
+ }>({
204
+ key: 'squad-sdk:session:pendingNavigation',
205
+ default: { hasPendingInviter: false },
206
+ effects: [secureStorageObjectEffect('SQUAD_SDK_PENDING_NAVIGATION')],
207
+ });
208
+
209
+ export type AttemptedVerification = { key: string; timestamp: number };
210
+ export const ATTEMPTED_VERIFICATION_WINDOW_MS = 10 * 60 * 1000;
211
+
212
+ export const reAttemptedVerifications = atom<AttemptedVerification[]>({
213
+ key: 'squad-sdk:auth:attempted-verifications',
214
+ default: [],
215
+ effects: [secureStorageArrayEffect('SQUAD_SDK_ATTEMPTED_VERIFICATIONS')],
216
+ });
217
+
218
+ export function pruneExpiredAttemptedVerifications(
219
+ entries: AttemptedVerification[],
220
+ now: number = Date.now(),
221
+ ): AttemptedVerification[] {
222
+ return entries.filter(e => now - e.timestamp < ATTEMPTED_VERIFICATION_WINDOW_MS);
223
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Squaddie of the Day atoms.
3
+ * Ported from squad-demo/src/atoms/squaddie-of-the-day.ts + sotd-animation.ts.
4
+ */
5
+ import { atom } from 'recoil';
6
+ import type { SquaddieOfTheDay } from '@squad-sports/core';
7
+
8
+ export const reSquaddieOfTheDay = atom<SquaddieOfTheDay | null>({
9
+ key: 'squad-sdk:sotd:data',
10
+ default: null,
11
+ });
12
+
13
+ export const reSOTDAnimationShown = atom<boolean>({
14
+ key: 'squad-sdk:sotd:animationShown',
15
+ default: false,
16
+ });
17
+
18
+ export const reSOTDIntroShown = atom<boolean>({
19
+ key: 'squad-sdk:sotd:introShown',
20
+ default: false,
21
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * CRDT (Conflict-free Replicated Data Type) implementation.
3
+ * Ported from squad-demo/src/atoms/sync/crdt.ts.
4
+ *
5
+ * Merges initial server state with local real-time updates,
6
+ * supporting insert, update, and delete operations.
7
+ */
8
+
9
+ export type CrdtArrayOperation = 'insert' | 'update' | 'delete';
10
+
11
+ export type CrdtArrayItem<ValueType> = {
12
+ operation: CrdtArrayOperation;
13
+ value: ValueType;
14
+ };
15
+
16
+ export type CrdtArray<KeyType, ValueType> = Map<KeyType, CrdtArrayItem<ValueType>>;
17
+
18
+ /**
19
+ * Compile a CRDT array by merging initial server data with local updates.
20
+ * - 'update' operations replace the initial item
21
+ * - 'delete' operations remove the item
22
+ * - 'insert' operations add new items not in the initial set
23
+ */
24
+ export function compileCrdt<KeyType, ValueType>(
25
+ initial: Array<ValueType>,
26
+ updates: CrdtArray<KeyType, ValueType>,
27
+ keyFrom: (value: ValueType) => KeyType,
28
+ ): Array<ValueType> {
29
+ const existing = new Map<KeyType, boolean>();
30
+
31
+ return [
32
+ ...initial
33
+ .map(item => {
34
+ const key = keyFrom(item);
35
+ const update = updates.get(key);
36
+ existing.set(key, true);
37
+
38
+ if (update?.operation === 'update') {
39
+ return update.value;
40
+ }
41
+ return item;
42
+ })
43
+ .filter(item => {
44
+ const key = keyFrom(item);
45
+ const update = updates.get(key);
46
+ existing.set(key, true);
47
+ return update?.operation !== 'delete';
48
+ }),
49
+ ...Array.from(updates.entries())
50
+ .filter(([key, { operation }]) => !existing.get(key) && operation !== 'delete')
51
+ .map(([_, { value }]) => value),
52
+ ];
53
+ }
54
+
55
+ /**
56
+ * Compile a single CRDT value (for individual items, not arrays).
57
+ */
58
+ export function compileCrdtSingle<ValueType>(
59
+ initial: ValueType,
60
+ update: CrdtArrayItem<ValueType> | null,
61
+ ): ValueType | undefined {
62
+ if (!update) return initial;
63
+
64
+ switch (update.operation) {
65
+ case 'update': return update.value;
66
+ case 'delete': return undefined;
67
+ case 'insert': return initial;
68
+ default: return initial;
69
+ }
70
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * DependableAtom — wrapper for Recoil atoms that provides subscribable
3
+ * change notifications outside of Recoil's update tree.
4
+ *
5
+ * Ported from squad-demo/src/atoms/sync/dependable.ts.
6
+ *
7
+ * Used sparingly for top-level state (API calls) that needs to be
8
+ * refreshable with propagated changes.
9
+ */
10
+
11
+ import {
12
+ atom,
13
+ AtomEffect,
14
+ atomFamily,
15
+ DefaultValue,
16
+ type Loadable,
17
+ type ReadWriteSelectorOptions,
18
+ type RecoilState,
19
+ type RecoilValue,
20
+ selector,
21
+ selectorFamily,
22
+ type SerializableParam,
23
+ type WrappedValue,
24
+ } from 'recoil';
25
+ import EventEmitter from 'eventemitter3';
26
+
27
+ type RecoilSetSelfFn<T> = (
28
+ param:
29
+ | T
30
+ | DefaultValue
31
+ | Promise<T | DefaultValue>
32
+ | WrappedValue<T>
33
+ | ((param: T | DefaultValue) => T | DefaultValue | WrappedValue<T>),
34
+ ) => void;
35
+
36
+ export type AtomEffectParams<T> = {
37
+ setSelf: RecoilSetSelfFn<T>;
38
+ getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>;
39
+ getLoadable: <S>(recoilValue: RecoilValue<S>) => Loadable<S>;
40
+ onSet: (param: (newValue: T, oldValue: T | DefaultValue, isReset: boolean) => void) => void;
41
+ };
42
+
43
+ type DependableAtomEffect<T, ReturnType> = (
44
+ param: {
45
+ subscribeToLoadable: <S>(
46
+ callback: ((value: T) => Loadable<void>) | ((value: T) => void),
47
+ ) => void;
48
+ subscribeToPromise: <S>(
49
+ callback: ((value: T) => Promise<void>) | ((value: T) => void),
50
+ ) => void;
51
+ } & AtomEffectParams<ReturnType>,
52
+ ) => void | (() => void);
53
+
54
+ // --- Base class ---
55
+
56
+ class Dependable<T> {
57
+ private emitter: EventEmitter;
58
+ protected state: RecoilState<T>;
59
+
60
+ constructor(state: RecoilState<T>) {
61
+ this.emitter = new EventEmitter();
62
+ this.state = state;
63
+ }
64
+
65
+ getEffect<S>(createEffect: DependableAtomEffect<T, S>) {
66
+ return (({ getPromise, getLoadable, ...atomTools }: any) =>
67
+ createEffect({
68
+ ...atomTools,
69
+ getPromise,
70
+ getLoadable,
71
+ subscribeToLoadable: this.subscribeToLoadable(getLoadable),
72
+ subscribeToPromise: this.subscribeToPromise(getPromise),
73
+ })) as unknown as AtomEffect<S>;
74
+ }
75
+
76
+ protected addListener(callback: (_value: T) => void) {
77
+ this.emitter.on('change', callback);
78
+ }
79
+
80
+ protected removeListener(callback: (_value: T) => void) {
81
+ this.emitter.off('change', callback);
82
+ }
83
+
84
+ protected emitChange(newValue: T) {
85
+ this.emitter.emit('change', newValue);
86
+ }
87
+
88
+ private subscribeToPromise(getPromise: (recoilValue: RecoilValue<T>) => Promise<T>) {
89
+ return (callback: (_value: T) => void) => {
90
+ getPromise(this.state).then(result => {
91
+ callback(result);
92
+ this.addListener(callback);
93
+ return () => this.removeListener(callback);
94
+ });
95
+ };
96
+ }
97
+
98
+ private subscribeToLoadable(getLoadable: (recoilValue: RecoilValue<T>) => Loadable<T>) {
99
+ return (callback: (_value: T) => void) => {
100
+ const loadable = getLoadable(this.state);
101
+ if (loadable.state === 'hasValue') {
102
+ callback(loadable.contents);
103
+ }
104
+ this.addListener(callback);
105
+ return () => this.removeListener(callback);
106
+ };
107
+ }
108
+ }
109
+
110
+ // --- Family base class ---
111
+
112
+ class DependableFamily<T, ParamType extends SerializableParam> {
113
+ private emitter: EventEmitter;
114
+ protected state: (param: ParamType) => RecoilState<T>;
115
+
116
+ constructor(state: (param: ParamType) => RecoilState<T>) {
117
+ this.emitter = new EventEmitter();
118
+ this.state = state;
119
+ }
120
+
121
+ protected emitChange(param: ParamType, newValue: T) {
122
+ this.emitter.emit(`change:${JSON.stringify(param)}`, newValue);
123
+ }
124
+
125
+ private addListener(param: ParamType, callback: (_value: T) => void) {
126
+ this.emitter.on(`change:${JSON.stringify(param)}`, callback);
127
+ }
128
+
129
+ private removeListener(param: ParamType, callback: (_value: T) => void) {
130
+ this.emitter.off(`change:${JSON.stringify(param)}`, callback);
131
+ }
132
+ }
133
+
134
+ // --- Public classes ---
135
+
136
+ export class DependableAtom<T> extends Dependable<T> {
137
+ constructor({
138
+ key,
139
+ effects,
140
+ ...props
141
+ }: {
142
+ key: string;
143
+ default?: T;
144
+ effects: DependableAtomEffect<any, T>[];
145
+ }) {
146
+ super(
147
+ atom<T>({
148
+ key,
149
+ ...props,
150
+ effects: [
151
+ ({ onSet }) => {
152
+ onSet(newValue => {
153
+ this.emitChange(newValue);
154
+ });
155
+ },
156
+ ...effects,
157
+ ] as AtomEffect<T>[],
158
+ }),
159
+ );
160
+ }
161
+
162
+ get atom() {
163
+ return this.state;
164
+ }
165
+ }
166
+
167
+ export class DependableSelector<T> extends Dependable<T> {
168
+ constructor({ key, set, get }: ReadWriteSelectorOptions<T>) {
169
+ super(
170
+ selector<T>({
171
+ key,
172
+ get,
173
+ set: (opts, newValue) => {
174
+ if (newValue instanceof DefaultValue) return;
175
+ this.emitChange(newValue);
176
+ set(opts, newValue);
177
+ },
178
+ }),
179
+ );
180
+ }
181
+
182
+ get selector() {
183
+ return this.state;
184
+ }
185
+ }
186
+
187
+ export class DependableAtomFamily<T, ParamType extends SerializableParam> extends DependableFamily<T, ParamType> {
188
+ constructor(params: {
189
+ key: string;
190
+ default?: (param: ParamType) => T;
191
+ effects: ((param: ParamType) => AtomEffect<T>)[];
192
+ }) {
193
+ super(
194
+ atomFamily<T, ParamType>({
195
+ key: params.key,
196
+ default: params.default,
197
+ effects: (param: ParamType) =>
198
+ [
199
+ ({ onSet }: any) => {
200
+ onSet((newValue: T) => {
201
+ this.emitChange(param, newValue);
202
+ });
203
+ },
204
+ ...params.effects.map(effect => effect(param)),
205
+ ] as AtomEffect<T>[],
206
+ }),
207
+ );
208
+ }
209
+
210
+ get atomFamily() {
211
+ return this.state;
212
+ }
213
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Feed sync atoms.
3
+ * Ported from squad-demo/src/atoms/sync/feed-v2.ts.
4
+ */
5
+ import { atom, atomFamily, selector } from 'recoil';
6
+ import type { Feed, Freestyle, FreestyleReaction } from '@squad-sports/core';
7
+ import { type CrdtArray } from './crdt';
8
+
9
+ // Initial feed from API
10
+ export const reInitialFeed = atom<Feed | null>({
11
+ key: 'squad-sdk:feed:initial:v2',
12
+ default: null,
13
+ });
14
+
15
+ // Real-time feed updates
16
+ export const feedUpdates = atom<CrdtArray<string, Freestyle>>({
17
+ key: 'squad-sdk:feed:updates',
18
+ default: new Map(),
19
+ });
20
+
21
+ // Feed expiry tick (refreshes every 60s)
22
+ export const feedExpiryTickAtom = atom<number>({
23
+ key: 'squad-sdk:feed:expiry-tick',
24
+ default: 0,
25
+ });
26
+
27
+ // Freestyle reactions per freestyle
28
+ export const freestyleReactionUpdates = atomFamily<CrdtArray<string, FreestyleReaction>, string>({
29
+ key: 'squad-sdk:freestyle:reactions',
30
+ default: new Map(),
31
+ });
32
+
33
+ // Freestyle-specific state
34
+ export const reFreestylePrompts = atom<unknown[]>({
35
+ key: 'squad-sdk:freestyle:prompts',
36
+ default: [],
37
+ });
38
+
39
+ export const reFreestyleCreating = atom<boolean>({
40
+ key: 'squad-sdk:freestyle:creating',
41
+ default: false,
42
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Message sync atoms.
3
+ * Ported from squad-demo/src/atoms/sync/messages.ts + message-prompts.ts.
4
+ */
5
+ import { atom, atomFamily } from 'recoil';
6
+ import type { Conversation, Message, MessageReaction } from '@squad-sports/core';
7
+
8
+ // Conversation per connection (initial from API)
9
+ export const reInitialConversationState = atomFamily<Conversation | null, string>({
10
+ key: 'squad-sdk:connection:messages:initial',
11
+ default: null,
12
+ });
13
+
14
+ // Messages per connection (compiled with real-time updates)
15
+ export const reConnectionMessages = atomFamily<Message[], string>({
16
+ key: 'squad-sdk:connection:messages',
17
+ default: [],
18
+ });
19
+
20
+ // Message reactions
21
+ export const reMessageReaction = atomFamily<MessageReaction | null, string>({
22
+ key: 'squad-sdk:message-reaction',
23
+ default: null,
24
+ });
25
+
26
+ // Failed message tracking
27
+ export interface FailedMessageInfo {
28
+ messageId: string;
29
+ connectionId: string;
30
+ error: string;
31
+ timestamp: number;
32
+ retryCount: number;
33
+ }
34
+
35
+ export const reMessageSendStatus = atom<Map<string, FailedMessageInfo>>({
36
+ key: 'squad-sdk:messages:send-status',
37
+ default: new Map(),
38
+ });
39
+
40
+ // Message prompts
41
+ export const reMessagePrompts = atom<unknown[]>({
42
+ key: 'squad-sdk:messages:prompts',
43
+ default: [],
44
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Offline support atoms.
3
+ * Ported from squad-demo/src/atoms/sync/offline-support.ts.
4
+ */
5
+ import { atom } from 'recoil';
6
+
7
+ export type OfflineActionType =
8
+ | 'message:create'
9
+ | 'freestyle:create'
10
+ | 'poll:response'
11
+ | 'connection:invite'
12
+ | 'message:reaction'
13
+ | 'freestyle:reaction'
14
+ | 'user:update';
15
+
16
+ export interface QueuedOfflineAction {
17
+ id: string;
18
+ type: OfflineActionType;
19
+ payload: unknown;
20
+ createdAt: number;
21
+ attempts: number;
22
+ }
23
+
24
+ export const isOnlineAtom = atom<boolean>({
25
+ key: 'squad-sdk:network:isOnline',
26
+ default: true,
27
+ });
28
+
29
+ export const pendingActionsAtom = atom<QueuedOfflineAction[]>({
30
+ key: 'squad-sdk:offline:pendingActions',
31
+ default: [],
32
+ });
33
+
34
+ export const offlineCacheAtom = atom<Map<string, { data: unknown; timestamp: number }>>({
35
+ key: 'squad-sdk:offline:cache',
36
+ default: new Map(),
37
+ });
38
+
39
+ export const shouldUseOfflineDataSelector = atom<boolean>({
40
+ key: 'squad-sdk:offline:shouldUseOfflineData',
41
+ default: false,
42
+ });