@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,113 @@
1
+ import { Linking } from 'react-native';
2
+
3
+ export interface DeepLinkRoute {
4
+ screen: string;
5
+ params?: Record<string, string>;
6
+ }
7
+
8
+ const SQUAD_SCHEME = 'squad';
9
+ const UNIVERSAL_LINK_HOST = 'app.withyoursquad.com';
10
+
11
+ /**
12
+ * Parses Squad deep links and universal links into navigation actions.
13
+ *
14
+ * Supported URLs:
15
+ * - squad://invite/{code}
16
+ * - squad://profile/{userId}
17
+ * - squad://message/{connectionId}
18
+ * - squad://poll/{pollId}
19
+ * - https://app.withyoursquad.com/invite/{code}
20
+ * - https://app.withyoursquad.com/profile/{userId}
21
+ */
22
+ export class DeepLinkHandler {
23
+ private static listener: ReturnType<typeof Linking.addEventListener> | null = null;
24
+
25
+ /**
26
+ * Parse a URL into a navigation route.
27
+ */
28
+ static parseUrl(url: string): DeepLinkRoute | null {
29
+ try {
30
+ let path: string;
31
+
32
+ if (url.startsWith(`${SQUAD_SCHEME}://`)) {
33
+ path = url.replace(`${SQUAD_SCHEME}://`, '');
34
+ } else if (url.includes(UNIVERSAL_LINK_HOST)) {
35
+ const urlObj = new URL(url);
36
+ path = urlObj.pathname.replace(/^\//, '');
37
+ } else {
38
+ return null;
39
+ }
40
+
41
+ const segments = path.split('/').filter(Boolean);
42
+ if (segments.length === 0) return null;
43
+
44
+ const [type, id] = segments;
45
+
46
+ switch (type) {
47
+ case 'invite':
48
+ return id ? { screen: 'Invite', params: { code: id } } : { screen: 'Invite' };
49
+ case 'profile':
50
+ return id ? { screen: 'Profile', params: { userId: id } } : null;
51
+ case 'message':
52
+ case 'messaging':
53
+ return id ? { screen: 'Messaging', params: { connectionId: id } } : null;
54
+ case 'poll':
55
+ return id ? { screen: 'PollResponse', params: { pollId: id } } : null;
56
+ case 'event':
57
+ case 'events':
58
+ return { screen: 'Events' };
59
+ case 'wallet':
60
+ return { screen: 'Wallet' };
61
+ case 'freestyle':
62
+ return { screen: 'FreestyleCreate' };
63
+ default:
64
+ return { screen: 'Home' };
65
+ }
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Start listening for deep links.
73
+ * Returns a cleanup function.
74
+ */
75
+ static startListening(onRoute: (route: DeepLinkRoute) => void): () => void {
76
+ // Handle initial URL (cold start)
77
+ Linking.getInitialURL().then(url => {
78
+ if (url) {
79
+ const route = DeepLinkHandler.parseUrl(url);
80
+ if (route) onRoute(route);
81
+ }
82
+ });
83
+
84
+ // Handle URLs while app is running
85
+ const subscription = Linking.addEventListener('url', ({ url }) => {
86
+ const route = DeepLinkHandler.parseUrl(url);
87
+ if (route) onRoute(route);
88
+ });
89
+
90
+ return () => subscription.remove();
91
+ }
92
+
93
+ /**
94
+ * React Navigation linking config for automatic deep link handling.
95
+ */
96
+ static get linkingConfig() {
97
+ return {
98
+ prefixes: [`${SQUAD_SCHEME}://`, `https://${UNIVERSAL_LINK_HOST}`],
99
+ config: {
100
+ screens: {
101
+ Home: '',
102
+ Invite: 'invite/:code?',
103
+ Profile: 'profile/:userId',
104
+ Messaging: 'message/:connectionId',
105
+ PollResponse: 'poll/:pollId',
106
+ Events: 'events',
107
+ Wallet: 'wallet',
108
+ FreestyleCreate: 'freestyle',
109
+ },
110
+ },
111
+ };
112
+ }
113
+ }
@@ -0,0 +1,313 @@
1
+ import EventEmitter from 'eventemitter3';
2
+ import type { SquadApiClient } from '@squad-sports/core';
3
+
4
+ /* eslint-disable */
5
+ // Ambient declaration for EventSource in React Native environments
6
+ interface IEventSource {
7
+ readyState: number;
8
+ close(): void;
9
+ addEventListener(type: string, listener: (event: any) => void): void;
10
+ removeEventListener(type: string, listener: (event: any) => void): void;
11
+ }
12
+ declare var EventSource: { new(url: string, options?: any): IEventSource; OPEN: number };
13
+ /* eslint-enable */
14
+
15
+ export type ConnectionQuality = 'good' | 'poor' | 'disconnected';
16
+
17
+ interface QueuedEvent {
18
+ type: string;
19
+ data: unknown;
20
+ attempts: number;
21
+ timestamp: number;
22
+ }
23
+
24
+ /**
25
+ * EventProcessor manages the real-time SSE connection and forwards
26
+ * server events to the rest of the app via an EventEmitter.
27
+ *
28
+ * Ported from squad-demo/src/clients/EventProcessor.ts (1119 lines),
29
+ * simplified for SDK use.
30
+ *
31
+ * Event types emitted:
32
+ * - `connection:quality` — connection state changes
33
+ * - `squad:member:add` / `squad:member:remove` — squad membership changes
34
+ * - `squad:invite:sent` / `squad:invite:accepted` — invite lifecycle
35
+ * - `connection:{id}:message:create` — new message in a connection
36
+ * - `message:create` — global new message (for badge counts)
37
+ * - `user:{id}:update` — user profile updated
38
+ * - `feed:update` — freestyle feed changed
39
+ * - `poll:update` — poll data changed
40
+ * - `attendee:update` — event attendance changed
41
+ * - `auth:invalid` — session expired
42
+ */
43
+ export class EventProcessor {
44
+ private static instance: EventProcessor | null = null;
45
+
46
+ readonly emitter = new EventEmitter();
47
+
48
+ private eventSource: IEventSource | null = null;
49
+ private apiClient: SquadApiClient | null = null;
50
+ private allowEvents = false;
51
+ private connectionQuality: ConnectionQuality = 'disconnected';
52
+ private reconnectAttempts = 0;
53
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
54
+ private eventBuffer: QueuedEvent[] = [];
55
+ private processedEventIds = new Map<string, number>();
56
+ private insertionCount = 0;
57
+
58
+ // Backoff config
59
+ private readonly INITIAL_BACKOFF = 1000;
60
+ private readonly MAX_BACKOFF = 30000;
61
+ private readonly BACKOFF_MULTIPLIER = 2;
62
+ private readonly MAX_RECONNECT_ATTEMPTS = 10;
63
+ private readonly MAX_BUFFER_SIZE = 500;
64
+
65
+ private constructor() {}
66
+
67
+ static get shared(): EventProcessor {
68
+ if (!EventProcessor.instance) {
69
+ EventProcessor.instance = new EventProcessor();
70
+ }
71
+ return EventProcessor.instance;
72
+ }
73
+
74
+ static reset(): void {
75
+ EventProcessor.instance?.disconnect();
76
+ EventProcessor.instance = null;
77
+ }
78
+
79
+ // --- Configuration ---
80
+
81
+ setApiClient(client: SquadApiClient): void {
82
+ this.apiClient = client;
83
+ }
84
+
85
+ setShouldAllowEvents(allow: boolean): void {
86
+ this.allowEvents = allow;
87
+ if (!allow) {
88
+ this.disconnect();
89
+ }
90
+ }
91
+
92
+ getConnectionQuality(): ConnectionQuality {
93
+ return this.connectionQuality;
94
+ }
95
+
96
+ // --- Connection lifecycle ---
97
+
98
+ /**
99
+ * Establish the SSE connection to the server.
100
+ * Called by the provider when a valid access token is available.
101
+ */
102
+ async connect(): Promise<void> {
103
+ if (!this.apiClient || !this.allowEvents) {
104
+ return;
105
+ }
106
+
107
+ const token = this.apiClient.currentToken;
108
+ if (!token) {
109
+ return;
110
+ }
111
+
112
+ // Disconnect existing connection
113
+ this.closeEventSource();
114
+
115
+ const baseUrl = this.apiClient.baseUrl;
116
+ const url = `${baseUrl}/v2/events`;
117
+
118
+ try {
119
+ // Create EventSource with auth header
120
+ // Note: In React Native, we use the vendor react-native-sse library
121
+ // which supports custom headers. For the SDK, we use a dynamic import.
122
+ let RNEventSource: any;
123
+ try {
124
+ RNEventSource = (await import('react-native-sse')).default;
125
+ } catch {
126
+ // react-native-sse not available — use native EventSource if available
127
+ if (typeof EventSource !== 'undefined') {
128
+ RNEventSource = EventSource;
129
+ } else {
130
+ console.warn('[EventProcessor] No EventSource implementation available');
131
+ return;
132
+ }
133
+ }
134
+
135
+ const es = new RNEventSource(url, {
136
+ headers: { Authorization: `Bearer ${token}` },
137
+ });
138
+
139
+ this.eventSource = es;
140
+ this.reconnectAttempts = 0;
141
+
142
+ // Handle connection open
143
+ es.addEventListener('open', () => {
144
+ this.updateConnectionQuality('good');
145
+ this.reconnectAttempts = 0;
146
+ this.flushEventBuffer();
147
+ });
148
+
149
+ // Handle incoming messages
150
+ es.addEventListener('message', (event: any) => {
151
+ if (!event?.data) return;
152
+
153
+ try {
154
+ const parsed = JSON.parse(event.data);
155
+ const type = parsed.type ?? event.type ?? 'unknown';
156
+ const data = parsed.data ?? parsed;
157
+
158
+ this.processEvent(type, data);
159
+ } catch {
160
+ // Non-JSON event — emit raw
161
+ this.processEvent('raw', event.data);
162
+ }
163
+ });
164
+
165
+ // Handle errors
166
+ es.addEventListener('error', () => {
167
+ this.updateConnectionQuality('disconnected');
168
+ this.closeEventSource();
169
+ this.scheduleReconnect();
170
+ });
171
+
172
+ // Handle specific event types the server sends
173
+ const serverEventTypes = [
174
+ 'connected',
175
+ 'squad:member:add', 'squad:member:remove',
176
+ 'squad:invite:sent', 'squad:invite:accepted',
177
+ 'feed:update', 'poll:update',
178
+ 'attendee:update',
179
+ ];
180
+
181
+ for (const eventType of serverEventTypes) {
182
+ es.addEventListener(eventType, (event: any) => {
183
+ try {
184
+ const data = event?.data ? JSON.parse(event.data) : {};
185
+ this.processEvent(eventType, data);
186
+ } catch {
187
+ this.processEvent(eventType, {});
188
+ }
189
+ });
190
+ }
191
+ } catch (error) {
192
+ console.error('[EventProcessor] Connection failed:', error);
193
+ this.updateConnectionQuality('disconnected');
194
+ this.scheduleReconnect();
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Disconnect the SSE connection.
200
+ */
201
+ disconnect(): void {
202
+ this.closeEventSource();
203
+ this.clearReconnectTimer();
204
+ this.updateConnectionQuality('disconnected');
205
+ }
206
+
207
+ // --- Event processing ---
208
+
209
+ private processEvent(type: string, data: unknown): void {
210
+ // Deduplication with TTL
211
+ const eventKey = `${type}:${JSON.stringify(data)}`;
212
+ const now = Date.now();
213
+ if (this.processedEventIds.has(eventKey)) return;
214
+ this.processedEventIds.set(eventKey, now);
215
+ this.insertionCount++;
216
+
217
+ // Prune stale entries every 100 insertions
218
+ if (this.insertionCount % 100 === 0) {
219
+ const oneHourAgo = now - 60 * 60 * 1000;
220
+ for (const [key, ts] of this.processedEventIds) {
221
+ if (ts < oneHourAgo) this.processedEventIds.delete(key);
222
+ }
223
+ }
224
+
225
+ // Hard cap at 5000
226
+ if (this.processedEventIds.size > 5000) {
227
+ const entries = Array.from(this.processedEventIds.entries())
228
+ .sort((a, b) => a[1] - b[1]);
229
+ const toRemove = entries.slice(0, entries.length - 3000);
230
+ for (const [key] of toRemove) this.processedEventIds.delete(key);
231
+ }
232
+
233
+ // Buffer if connection is poor
234
+ if (this.connectionQuality === 'poor') {
235
+ this.addToBuffer({ type, data, attempts: 0, timestamp: Date.now() });
236
+ return;
237
+ }
238
+
239
+ // Emit the event
240
+ this.emitter.emit(type, data);
241
+
242
+ // Also emit global message event for connection-specific messages
243
+ if (/^connection:.+:message:create$/.test(type)) {
244
+ this.emitter.emit('message:create', data);
245
+ }
246
+ }
247
+
248
+ // --- Buffer management ---
249
+
250
+ private addToBuffer(event: QueuedEvent): void {
251
+ if (this.eventBuffer.length >= this.MAX_BUFFER_SIZE) {
252
+ this.eventBuffer.shift(); // Drop oldest
253
+ }
254
+ this.eventBuffer.push(event);
255
+ }
256
+
257
+ private flushEventBuffer(): void {
258
+ const events = [...this.eventBuffer];
259
+ this.eventBuffer = [];
260
+
261
+ for (const event of events) {
262
+ this.emitter.emit(event.type, event.data);
263
+ }
264
+ }
265
+
266
+ // --- Reconnection ---
267
+
268
+ private scheduleReconnect(): void {
269
+ if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
270
+ console.warn('[EventProcessor] Max reconnect attempts reached');
271
+ this.emitter.emit('connection:maxRetriesReached');
272
+ return;
273
+ }
274
+
275
+ this.clearReconnectTimer();
276
+ this.reconnectAttempts++;
277
+
278
+ const delay = Math.min(
279
+ this.INITIAL_BACKOFF * Math.pow(this.BACKOFF_MULTIPLIER, this.reconnectAttempts - 1),
280
+ this.MAX_BACKOFF,
281
+ );
282
+ const jitter = Math.random() * 0.1 * delay;
283
+
284
+ this.reconnectTimer = setTimeout(() => {
285
+ this.connect();
286
+ }, delay + jitter);
287
+ }
288
+
289
+ private clearReconnectTimer(): void {
290
+ if (this.reconnectTimer) {
291
+ clearTimeout(this.reconnectTimer);
292
+ this.reconnectTimer = null;
293
+ }
294
+ }
295
+
296
+ // --- Helpers ---
297
+
298
+ private closeEventSource(): void {
299
+ if (this.eventSource) {
300
+ try {
301
+ (this.eventSource as any).close();
302
+ } catch {}
303
+ this.eventSource = null;
304
+ }
305
+ }
306
+
307
+ private updateConnectionQuality(quality: ConnectionQuality): void {
308
+ if (quality !== this.connectionQuality) {
309
+ this.connectionQuality = quality;
310
+ this.emitter.emit('connection:quality', quality);
311
+ }
312
+ }
313
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useState, useCallback } from 'react';
2
+
3
+ type NetworkListener = (isOnline: boolean) => void;
4
+
5
+ /**
6
+ * Monitors network connectivity and notifies listeners.
7
+ * Uses @react-native-community/netinfo when available.
8
+ */
9
+ export class NetworkMonitor {
10
+ private static instance: NetworkMonitor | null = null;
11
+ private listeners = new Set<NetworkListener>();
12
+ private isOnline = true;
13
+ private unsubscribe: (() => void) | null = null;
14
+
15
+ static get shared(): NetworkMonitor {
16
+ if (!NetworkMonitor.instance) {
17
+ NetworkMonitor.instance = new NetworkMonitor();
18
+ }
19
+ return NetworkMonitor.instance;
20
+ }
21
+
22
+ async startMonitoring(): Promise<void> {
23
+ try {
24
+ const NetInfo = (await import('@react-native-community/netinfo')).default;
25
+ this.unsubscribe = NetInfo.addEventListener((state: { isConnected: boolean | null; isInternetReachable: boolean | null }) => {
26
+ const online = !!(state.isConnected && state.isInternetReachable !== false);
27
+ this.updateOnlineState(online);
28
+ });
29
+ } catch {
30
+ // NetInfo not available — assume online
31
+ this.isOnline = true;
32
+ }
33
+ }
34
+
35
+ stopMonitoring(): void {
36
+ this.unsubscribe?.();
37
+ this.unsubscribe = null;
38
+ }
39
+
40
+ addListener(listener: NetworkListener): void {
41
+ this.listeners.add(listener);
42
+ }
43
+
44
+ removeListener(listener: NetworkListener): void {
45
+ this.listeners.delete(listener);
46
+ }
47
+
48
+ getIsOnline(): boolean {
49
+ return this.isOnline;
50
+ }
51
+
52
+ private updateOnlineState(online: boolean): void {
53
+ if (online === this.isOnline) return;
54
+ this.isOnline = online;
55
+
56
+ // Snapshot listeners to avoid mutation during iteration
57
+ const snapshot = [...this.listeners];
58
+ for (const listener of snapshot) {
59
+ listener(online);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Hook that provides current network connectivity status.
66
+ */
67
+ export function useNetworkStatus() {
68
+ const [isOnline, setIsOnline] = useState(true);
69
+
70
+ useEffect(() => {
71
+ const monitor = NetworkMonitor.shared;
72
+ monitor.startMonitoring();
73
+ setIsOnline(monitor.getIsOnline());
74
+
75
+ const listener = (online: boolean) => setIsOnline(online);
76
+ monitor.addListener(listener);
77
+
78
+ return () => {
79
+ monitor.removeListener(listener);
80
+ };
81
+ }, []);
82
+
83
+ return isOnline;
84
+ }
@@ -0,0 +1,133 @@
1
+ import type { StorageAdapter } from '@squad-sports/core';
2
+
3
+ export type OfflineActionType =
4
+ | 'message:create'
5
+ | 'freestyle:create'
6
+ | 'poll:response'
7
+ | 'connection:invite'
8
+ | 'message:reaction'
9
+ | 'freestyle:reaction'
10
+ | 'user:update';
11
+
12
+ export interface OfflineAction {
13
+ id: string;
14
+ type: OfflineActionType;
15
+ payload: unknown;
16
+ createdAt: number;
17
+ attempts: number;
18
+ }
19
+
20
+ const STORAGE_KEY = 'SQUAD_SDK_OFFLINE_QUEUE';
21
+
22
+ /**
23
+ * Queues mutations when offline and replays them when connectivity returns.
24
+ * Ported from squad-demo/src/atoms/sync/offline-support.ts.
25
+ */
26
+ export class OfflineQueue {
27
+ private queue: OfflineAction[] = [];
28
+ private isProcessing = false;
29
+ private storage: StorageAdapter | null = null;
30
+
31
+ constructor(storage?: StorageAdapter) {
32
+ this.storage = storage ?? null;
33
+ this.loadFromStorage();
34
+ }
35
+
36
+ /**
37
+ * Add an action to the offline queue.
38
+ */
39
+ enqueue(type: OfflineActionType, payload: unknown): void {
40
+ const action: OfflineAction = {
41
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
42
+ type,
43
+ payload,
44
+ createdAt: Date.now(),
45
+ attempts: 0,
46
+ };
47
+
48
+ this.queue.push(action);
49
+ this.persistToStorage();
50
+ }
51
+
52
+ /**
53
+ * Process all queued actions.
54
+ * Call this when connectivity is restored.
55
+ */
56
+ async processQueue(
57
+ executor: (action: OfflineAction) => Promise<boolean>,
58
+ ): Promise<{ succeeded: number; failed: number }> {
59
+ if (this.isProcessing || this.queue.length === 0) {
60
+ return { succeeded: 0, failed: 0 };
61
+ }
62
+
63
+ this.isProcessing = true;
64
+ let succeeded = 0;
65
+ let failed = 0;
66
+
67
+ const pending = [...this.queue];
68
+ this.queue = [];
69
+
70
+ for (const action of pending) {
71
+ try {
72
+ action.attempts++;
73
+ const success = await executor(action);
74
+
75
+ if (success) {
76
+ succeeded++;
77
+ } else {
78
+ // Re-queue if under max attempts
79
+ if (action.attempts < 3) {
80
+ this.queue.push(action);
81
+ } else {
82
+ failed++;
83
+ }
84
+ }
85
+ } catch {
86
+ if (action.attempts < 3) {
87
+ this.queue.push(action);
88
+ } else {
89
+ failed++;
90
+ }
91
+ }
92
+ }
93
+
94
+ this.isProcessing = false;
95
+ this.persistToStorage();
96
+
97
+ return { succeeded, failed };
98
+ }
99
+
100
+ /**
101
+ * Get the number of pending actions.
102
+ */
103
+ get pendingCount(): number {
104
+ return this.queue.length;
105
+ }
106
+
107
+ /**
108
+ * Clear all queued actions.
109
+ */
110
+ clear(): void {
111
+ this.queue = [];
112
+ this.persistToStorage();
113
+ }
114
+
115
+ // --- Persistence ---
116
+
117
+ private async loadFromStorage(): Promise<void> {
118
+ if (!this.storage) return;
119
+ try {
120
+ const raw = await this.storage.getItem(STORAGE_KEY);
121
+ if (raw) {
122
+ this.queue = JSON.parse(raw);
123
+ }
124
+ } catch {}
125
+ }
126
+
127
+ private async persistToStorage(): Promise<void> {
128
+ if (!this.storage) return;
129
+ try {
130
+ await this.storage.setItem(STORAGE_KEY, JSON.stringify(this.queue));
131
+ } catch {}
132
+ }
133
+ }