@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,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
|
+
});
|