@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +46 -0
- package/src/SquadExperience.tsx +200 -0
- package/src/SquadProvider.tsx +232 -0
- package/src/SquadSportsSDK.ts +286 -0
- package/src/__tests__/DeepLinkHandler.test.ts +101 -0
- package/src/__tests__/ErrorBoundary.test.tsx +161 -0
- package/src/__tests__/EventProcessor.test.ts +241 -0
- package/src/__tests__/PushNotificationHandler.test.ts +91 -0
- package/src/__tests__/SecureStorage.test.ts +62 -0
- package/src/__tests__/SquadSportsSDK.test.ts +278 -0
- package/src/__tests__/VerificationCooldown.test.ts +153 -0
- package/src/components/ErrorBoundary.tsx +129 -0
- package/src/components/SOTD/SOTDComponents.tsx +101 -0
- package/src/components/audio/AudioPlayerRow.tsx +189 -0
- package/src/components/audio/recording/AudioRecording.tsx +232 -0
- package/src/components/communities/CommunityComponents.tsx +78 -0
- package/src/components/dialogs/AllDialogs.tsx +123 -0
- package/src/components/dialogs/ConfirmDialog.tsx +77 -0
- package/src/components/dialogs/PermissionDialog.tsx +132 -0
- package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
- package/src/components/events/EventComponents.tsx +93 -0
- package/src/components/feed/ChatBannerCard.tsx +94 -0
- package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
- package/src/components/feed/FreestyleCard.tsx +119 -0
- package/src/components/feed/InterstitialOverlay.tsx +190 -0
- package/src/components/feed/PollCard.tsx +158 -0
- package/src/components/feed/SponsoredContentCard.tsx +118 -0
- package/src/components/freestyle/FreestyleComponents.tsx +148 -0
- package/src/components/index.ts +42 -0
- package/src/components/message/MessageCard.tsx +166 -0
- package/src/components/message/MessageComponents.tsx +143 -0
- package/src/components/poll/PollComponents.tsx +226 -0
- package/src/components/sentinels/Sentinels.tsx +175 -0
- package/src/components/squad/FeedSquad.tsx +54 -0
- package/src/components/toasts/Toasts.tsx +88 -0
- package/src/components/ux/RootUXComponents.tsx +157 -0
- package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
- package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
- package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
- package/src/components/ux/buttons/Button.tsx +37 -0
- package/src/components/ux/buttons/InfoButton.tsx +24 -0
- package/src/components/ux/buttons/XButton.tsx +27 -0
- package/src/components/ux/carousel/Carousel.tsx +134 -0
- package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
- package/src/components/ux/errors/ErrorHint.tsx +46 -0
- package/src/components/ux/inputs/CodeInput.tsx +121 -0
- package/src/components/ux/inputs/DatePicker.tsx +76 -0
- package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
- package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
- package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
- package/src/components/ux/inputs/TextInput.tsx +58 -0
- package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
- package/src/components/ux/layout/BlurOverlay.tsx +26 -0
- package/src/components/ux/layout/CrossFade.tsx +30 -0
- package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
- package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
- package/src/components/ux/layout/NetworkBanner.tsx +64 -0
- package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
- package/src/components/ux/layout/RefreshControl.tsx +7 -0
- package/src/components/ux/layout/Screen.tsx +31 -0
- package/src/components/ux/layout/ScreenHeader.tsx +89 -0
- package/src/components/ux/layout/TabBar.tsx +39 -0
- package/src/components/ux/layout/Toast.tsx +116 -0
- package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
- package/src/components/ux/navigation/BackButton.tsx +29 -0
- package/src/components/ux/navigation/LinkButton.tsx +21 -0
- package/src/components/ux/navigation/UrlButton.tsx +25 -0
- package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
- package/src/components/ux/shapes/Shapes.tsx +23 -0
- package/src/components/ux/text/Typography.tsx +28 -0
- package/src/components/ux/user-image/UserImage.tsx +75 -0
- package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
- package/src/components/wallet/WalletComponents.tsx +116 -0
- package/src/contexts/AuthContext.tsx +45 -0
- package/src/contexts/EventProcessorContext.tsx +41 -0
- package/src/contexts/PlayerQueueContext.tsx +95 -0
- package/src/hooks/useAuth.ts +23 -0
- package/src/hooks/useDataRefresh.ts +30 -0
- package/src/hooks/useEventProcessor.ts +6 -0
- package/src/hooks/useImageOptimization.ts +59 -0
- package/src/hooks/useOnboardingStepGuard.ts +36 -0
- package/src/hooks/usePendingNavigation.ts +26 -0
- package/src/hooks/useSquadData.ts +84 -0
- package/src/hooks/useUserCreated.ts +25 -0
- package/src/hooks/useUserUpdate.ts +25 -0
- package/src/hooks/useViewabilityTracker.ts +40 -0
- package/src/index.ts +109 -0
- package/src/navigation/SquadNavigator.tsx +262 -0
- package/src/realtime/DeepLinkHandler.ts +113 -0
- package/src/realtime/EventProcessor.ts +313 -0
- package/src/realtime/NetworkMonitor.ts +84 -0
- package/src/realtime/OfflineQueue.ts +133 -0
- package/src/realtime/PushNotificationHandler.ts +125 -0
- package/src/realtime/useRealtimeSync.ts +84 -0
- package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
- package/src/screens/auth/EnterCodeScreen.tsx +253 -0
- package/src/screens/auth/EnterEmailScreen.tsx +234 -0
- package/src/screens/auth/LandingScreen.tsx +90 -0
- package/src/screens/auth/LoginScreen.tsx +126 -0
- package/src/screens/events/EventScreen.tsx +163 -0
- package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
- package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
- package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
- package/src/screens/home/HomeScreen.tsx +365 -0
- package/src/screens/home/slivers/SquadCircle.tsx +77 -0
- package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
- package/src/screens/invite/InviteScreen.tsx +175 -0
- package/src/screens/messaging/MessagingScreen.tsx +360 -0
- package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
- package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
- package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
- package/src/screens/polls/PollResponseScreen.tsx +229 -0
- package/src/screens/polls/PollSummationScreen.tsx +78 -0
- package/src/screens/profile/ProfileScreen.tsx +234 -0
- package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
- package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
- package/src/screens/settings/EditProfileScreen.tsx +154 -0
- package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
- package/src/screens/settings/SettingsScreen.tsx +194 -0
- package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
- package/src/screens/wallet/WalletScreen.tsx +174 -0
- package/src/services/AuthStateManager.ts +93 -0
- package/src/services/NavigationService.ts +40 -0
- package/src/services/UserDataManager.ts +59 -0
- package/src/services/UserUpdateService.ts +31 -0
- package/src/services/VerificationStateManager.ts +41 -0
- package/src/squad-line/CallScreen.tsx +158 -0
- package/src/squad-line/IncomingCallOverlay.tsx +113 -0
- package/src/squad-line/SquadLineClient.ts +327 -0
- package/src/squad-line/useSquadLine.ts +80 -0
- package/src/state/audio.ts +38 -0
- package/src/state/client.ts +26 -0
- package/src/state/communities.ts +45 -0
- package/src/state/contacts.ts +28 -0
- package/src/state/device-info.ts +22 -0
- package/src/state/events.ts +16 -0
- package/src/state/features.ts +57 -0
- package/src/state/index.ts +121 -0
- package/src/state/invitations.ts +16 -0
- package/src/state/modal-keys.ts +63 -0
- package/src/state/modal-queue.ts +104 -0
- package/src/state/navigation.ts +34 -0
- package/src/state/permissions.ts +43 -0
- package/src/state/session.ts +223 -0
- package/src/state/squaddie-of-the-day.ts +21 -0
- package/src/state/sync/crdt.ts +70 -0
- package/src/state/sync/dependable.ts +213 -0
- package/src/state/sync/feed-v2.ts +42 -0
- package/src/state/sync/messages.ts +44 -0
- package/src/state/sync/offline-support.ts +42 -0
- package/src/state/sync/polls.ts +37 -0
- package/src/state/sync/refresh.ts +24 -0
- package/src/state/sync/squad-v2.ts +25 -0
- package/src/state/ui.ts +36 -0
- package/src/state/user.ts +46 -0
- package/src/state/wallet.ts +26 -0
- package/src/storage/SecureStorage.ts +77 -0
- package/src/theme/ThemeContext.tsx +159 -0
- 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
|
+
}
|