@tastic/profile 0.14.1 → 0.16.0
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/README.md +46 -11
- package/dist/index.d.mts +67 -30
- package/dist/index.d.ts +67 -30
- package/dist/index.js +429 -237
- package/dist/index.mjs +393 -206
- package/package.json +9 -9
- package/src/IdentityChip.tsx +8 -2
- package/src/OptionPicker.tsx +64 -0
- package/src/PickerMenu.tsx +512 -0
- package/src/ProfileColorContext.tsx +30 -8
- package/src/ProfilePicker.tsx +61 -426
- package/src/ProfilesManager.tsx +9 -6
- package/src/SwatchLayer.tsx +28 -0
- package/src/index.ts +4 -3
- package/src/redux/resolveInitialProfiles.ts +3 -2
- package/src/redux/seatColorsSlice.ts +79 -29
- package/src/redux/useSharedProfilesSync.ts +12 -6
- package/src/sharedProfileStore.ts +87 -11
package/src/ProfilesManager.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
|
|
1
|
+
import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
3
|
import { useFocusChain } from '@rific/focus-chain'
|
|
4
4
|
import { useToast } from '@rific/toaster'
|
|
5
|
-
import {
|
|
5
|
+
import type { PopoverHost } from '@tastic/hud/chrome'
|
|
6
|
+
import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from '@tastic/hud/chrome'
|
|
6
7
|
import { ComponentProps, ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
7
|
-
import { KeyboardAvoidingView, Platform,
|
|
8
|
+
import { KeyboardAvoidingView, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
8
9
|
import { Icon, Text, TextInput } from 'react-native-paper'
|
|
9
10
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
10
11
|
|
|
@@ -242,9 +243,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
242
243
|
actually keeps a focused row's own fields scrollable into view above the keyboard instead of
|
|
243
244
|
stuck behind it. keyboardShouldPersistTaps='handled' on the ScrollView below is what lets
|
|
244
245
|
tapping a *different* row (e.g. a color swatch, or another profile entirely) register on the
|
|
245
|
-
first tap while a field elsewhere is still focused, instead of just dismissing the keyboard.
|
|
246
|
+
first tap while a field elsewhere is still focused, instead of just dismissing the keyboard. The rows
|
|
247
|
+
fade out at an edge while there is more past it, the same cue as the Settings sheet and the
|
|
248
|
+
Achievements screen (Jay, 2026-10-16: every scrolling screen alike). */}
|
|
246
249
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardAvoiding}>
|
|
247
|
-
<
|
|
250
|
+
<FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
|
|
248
251
|
{rows.map((row) => {
|
|
249
252
|
if (row.kind === 'new') {
|
|
250
253
|
if (editingId !== 'new') {
|
|
@@ -283,7 +286,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
283
286
|
</TouchableRipple>
|
|
284
287
|
)
|
|
285
288
|
})}
|
|
286
|
-
</
|
|
289
|
+
</FadingScrollView>
|
|
287
290
|
</KeyboardAvoidingView>
|
|
288
291
|
|
|
289
292
|
{/* hud's ConfirmDialog, the one confirm card every app in the fleet uses, so this reads like the
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { StyleSheet, View } from 'react-native'
|
|
2
|
+
|
|
3
|
+
import { type ProfileSwatchRenderer } from './ProfileColorContext'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
render: ProfileSwatchRenderer
|
|
7
|
+
// The drawn color, handed to `render` as is.
|
|
8
|
+
color: string
|
|
9
|
+
// The circle's diameter: the swatch's own size, less any ring drawn inside it.
|
|
10
|
+
size: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// A ProfileColorProvider `renderSwatch` drawing in place of a swatch's flat fill: absolutely placed at
|
|
14
|
+
// the swatch's top left (inside any border), behind the mark drawn after it, and clipped to the circle
|
|
15
|
+
// so a square drawing still reads as the round swatch. Not exported from index.ts.
|
|
16
|
+
export function SwatchLayer({ render, color, size }: Props) {
|
|
17
|
+
return <View style={[styles.layer, { borderRadius: size / 2, height: size, width: size }]}>{render(color, size)}</View>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const styles = StyleSheet.create({
|
|
21
|
+
layer: {
|
|
22
|
+
left: 0,
|
|
23
|
+
overflow: 'hidden',
|
|
24
|
+
pointerEvents: 'none',
|
|
25
|
+
position: 'absolute',
|
|
26
|
+
top: 0
|
|
27
|
+
}
|
|
28
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
export { getOptionPickerContentSize, OptionPicker, type OptionPickerOption } from './OptionPicker'
|
|
1
2
|
export { ProfileChip } from './ProfileChip'
|
|
2
|
-
export { ProfileColorProvider, useProfileColorPreview, useProfileDisplayColor } from './ProfileColorContext'
|
|
3
|
+
export { ProfileColorProvider, type ProfileSwatchRenderer, useProfileColorPreview, useProfileDisplayColor, useProfileSwatchRenderer } from './ProfileColorContext'
|
|
3
4
|
export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerColorSection, type ProfilePickerContentSize } from './ProfilePicker'
|
|
4
5
|
export { type ProfileEditPatch, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
|
|
5
6
|
export { ProfilesScreen, type ProfilesScreenProps } from './ProfilesScreen'
|
|
@@ -8,8 +9,8 @@ export { createProfileExtensionSlice, type ProfileExtensionActions, type Profile
|
|
|
8
9
|
export { createProfileSelectionSlice, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, type SelectProfilePayload } from './redux/profileSelectionSlice'
|
|
9
10
|
export { type CreateProfileInput, createProfileRecord, profilesActions, profilesReducer, type UpdateProfilePayload } from './redux/profilesSlice'
|
|
10
11
|
export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
|
|
11
|
-
export { createSeatColorsSlice, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type
|
|
12
|
+
export { createSeatColorsSlice, type ProfileColorPick, profileSeatColor, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileColorPayload } from './redux/seatColorsSlice'
|
|
12
13
|
export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
|
|
13
14
|
export { avoidSeatColorClash } from './seatColorClash'
|
|
14
|
-
export { isSharedProfileStoreAvailable, loadSharedProfiles, readSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
|
|
15
|
+
export { isSharedProfileStoreAvailable, loadSharedProfiles, readSharedProfiles, saveSharedProfiles, subscribeSharedProfiles } from './sharedProfileStore'
|
|
15
16
|
export type { Profile } from './types'
|
|
@@ -7,8 +7,9 @@ import { Profile } from '../types'
|
|
|
7
7
|
// hand-rolled an identical version of this exact decision inline in its own useProfiles.tsx; this
|
|
8
8
|
// is that logic, extracted verbatim.
|
|
9
9
|
//
|
|
10
|
-
// - Shared store unavailable (Android
|
|
11
|
-
// the entitlement was added): `localFallback` IS the
|
|
10
|
+
// - Shared store unavailable (Android, a web page that can't write localStorage, or an iOS build
|
|
11
|
+
// that hasn't run `expo prebuild` since the entitlement was added): `localFallback` IS the
|
|
12
|
+
// roster, verbatim.
|
|
12
13
|
// - Shared store available, but its roster couldn't be read (readSharedProfiles' null):
|
|
13
14
|
// `localFallback`, and nothing is written. Seeding here would overwrite every other app's
|
|
14
15
|
// profiles with this app's local snapshot.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { Profile } from '../types'
|
|
1
2
|
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
3
|
+
import { profilesActions } from './profilesSlice'
|
|
2
4
|
|
|
3
5
|
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
4
6
|
// dependency on the package itself - this factory otherwise has no opinion on whether the
|
|
@@ -12,21 +14,32 @@ export interface SetLastGuestColorPayload<TSeat> {
|
|
|
12
14
|
seat: TSeat
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
// A color picked for a profile in this game: it holds while the profile keeps the color it had
|
|
18
|
+
// when the pick was made (see profileSeatColor).
|
|
19
|
+
export interface ProfileColorPick {
|
|
20
|
+
color: string
|
|
21
|
+
profileColor: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SetProfileColorPayload {
|
|
25
|
+
// The profile the pick is for, with its own color as it is now.
|
|
26
|
+
profile: Pick<Profile, 'id' | 'color'>
|
|
27
|
+
// The pick. Null, or the profile's own color, drops it, so the seat follows the profile's color
|
|
28
|
+
// again.
|
|
16
29
|
color: string | null
|
|
17
|
-
seat: TSeat
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
export interface SeatColorsActions<TSeat> {
|
|
21
33
|
setLastCpuColor: ActionCreator<string>
|
|
22
34
|
setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>
|
|
23
|
-
|
|
35
|
+
setProfileColor: ActionCreator<SetProfileColorPayload>
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
export type SeatColorsState<TSeat extends string | number> = {
|
|
27
39
|
lastCpuColor: string
|
|
28
40
|
lastGuestColor: Record<TSeat, string>
|
|
29
|
-
|
|
41
|
+
// Each profile's pick in this game, by profile id. A profile not listed shows its own color.
|
|
42
|
+
profileColors: Record<string, ProfileColorPick>
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
export interface SeatColorsSlice<TSeat extends string | number> {
|
|
@@ -34,24 +47,33 @@ export interface SeatColorsSlice<TSeat extends string | number> {
|
|
|
34
47
|
reducer: (state: SeatColorsState<TSeat> | undefined, action: { type: string; payload?: unknown }) => SeatColorsState<TSeat>
|
|
35
48
|
}
|
|
36
49
|
|
|
50
|
+
function sameColor(a: string, b: string) {
|
|
51
|
+
return a.toLowerCase() === b.toLowerCase()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The color a seat with `profile` selected shows: the profile's pick in this game, while the profile
|
|
55
|
+
// still has the color the pick was made against, else the profile's own color. So a new color for
|
|
56
|
+
// the profile itself (on any game's Profiles screen, this one or a sibling's through the shared
|
|
57
|
+
// roster) replaces an older pick, while a rename or a new tag keeps it.
|
|
58
|
+
export function profileSeatColor(profile: Pick<Profile, 'id' | 'color'>, profileColors: Record<string, ProfileColorPick>): string {
|
|
59
|
+
const pick = profileColors[profile.id]
|
|
60
|
+
return pick && sameColor(pick.profileColor, profile.color) ? pick.color : profile.color
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
// Which color a seat should show right now, remembered independently for three sources that must
|
|
38
|
-
// never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
64
|
+
// never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and each
|
|
65
|
+
// profile's own pick in this game (`profileColors`, by profile id, read through profileSeatColor).
|
|
66
|
+
// A profile's pick follows the profile, not the seat: it is kept when the seat switches to another
|
|
67
|
+
// profile and back, when the profile moves to the other seat, and across relaunches, the same as
|
|
68
|
+
// Solitaire's per-profile table color (2026-10-16; until then it was a per-seat `profileOverride`
|
|
69
|
+
// that any profile change on the seat cleared). Picking the profile's own color drops the pick, and
|
|
70
|
+
// so do a new color for the profile itself and deleting it (this package's profilesActions, which
|
|
71
|
+
// this reducer also listens to). Five @tastic games (AirHockey, BoxHockey,
|
|
72
|
+
// LightCycles, Pong, Snake) use this factory; the first four each hand-rolled an identical
|
|
73
|
+
// dedicated seatColorsSlice.ts before it existed.
|
|
46
74
|
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// than in a standalone slice of their own. There's no separate `seatColors` sub-state there for this
|
|
50
|
-
// factory's reducer to own without restructuring gameSlice's top-level shape to nest these three
|
|
51
|
-
// fields under their own key first - a real state-shape migration (and a redux-persist rehydration
|
|
52
|
-
// concern, see gameSlice's own REHYDRATE extraReducer), not a drop-in swap. This factory
|
|
53
|
-
// intentionally targets the standalone-slice shape the other four apps already have; Snake staying
|
|
54
|
-
// hand-rolled inside gameSlice is a deliberate scope call for this pass, not an oversight.
|
|
75
|
+
// A store saved before profileColors existed has a `profileOverride` key instead; REHYDRATE drops
|
|
76
|
+
// it, so every profile starts on its own color once.
|
|
55
77
|
//
|
|
56
78
|
// `namespace` prefixes every action type (e.g. 'seatColors/loadout/setLastGuestColor') so an app
|
|
57
79
|
// with more than one independent seat-colors concern never collides between them - same convention
|
|
@@ -71,26 +93,54 @@ export interface SeatColorsSlice<TSeat extends string | number> {
|
|
|
71
93
|
export function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey = 'seatColors'): SeatColorsSlice<TSeat> {
|
|
72
94
|
const setLastCpuColor = createActionCreator<string>(`seatColors/${namespace}/setLastCpuColor`)
|
|
73
95
|
const setLastGuestColor = createActionCreator<SetLastGuestColorPayload<TSeat>>(`seatColors/${namespace}/setLastGuestColor`)
|
|
74
|
-
const
|
|
96
|
+
const setProfileColor = createActionCreator<SetProfileColorPayload>(`seatColors/${namespace}/setProfileColor`)
|
|
97
|
+
|
|
98
|
+
function withoutPick(state: SeatColorsState<TSeat>, id: string): SeatColorsState<TSeat> {
|
|
99
|
+
if (!state.profileColors[id]) return state
|
|
100
|
+
const profileColors = { ...state.profileColors }
|
|
101
|
+
delete profileColors[id]
|
|
102
|
+
return { ...state, profileColors }
|
|
103
|
+
}
|
|
75
104
|
|
|
76
105
|
function reducer(state: SeatColorsState<TSeat> = defaultState, action: { type: string; payload?: unknown }): SeatColorsState<TSeat> {
|
|
77
106
|
// redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
|
|
78
107
|
// entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
|
|
79
108
|
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) -
|
|
80
|
-
// so a device with a blob persisted before
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
109
|
+
// so a device with a blob persisted before a field existed would otherwise rehydrate without
|
|
110
|
+
// it, and every consumer indexes into these fields with no fallback, throwing. Spreading
|
|
111
|
+
// `defaultState` first, then the incoming persisted value, backfills exactly that gap. A
|
|
112
|
+
// reducer that returns a new object here keeps autoMergeLevel1 from putting the saved blob back
|
|
113
|
+
// over it, which is what lets the old `profileOverride` key be dropped.
|
|
85
114
|
if (action.type === REHYDRATE) {
|
|
86
115
|
const persisted = (action.payload as Record<string, Partial<SeatColorsState<TSeat>>> | undefined)?.[mountKey]
|
|
87
|
-
|
|
116
|
+
const next: SeatColorsState<TSeat> & { profileOverride?: unknown } = { ...defaultState, ...state, ...persisted }
|
|
117
|
+
delete next.profileOverride
|
|
118
|
+
return next
|
|
88
119
|
}
|
|
89
120
|
if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload }
|
|
90
121
|
if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } as Record<TSeat, string> }
|
|
91
|
-
if (
|
|
122
|
+
if (setProfileColor.match(action)) {
|
|
123
|
+
const { profile, color } = action.payload
|
|
124
|
+
if (color === null || sameColor(color, profile.color)) return withoutPick(state, profile.id)
|
|
125
|
+
return { ...state, profileColors: { ...state.profileColors, [profile.id]: { color, profileColor: profile.color } } }
|
|
126
|
+
}
|
|
127
|
+
if (profilesActions.remove.match(action)) return withoutPick(state, action.payload)
|
|
128
|
+
// A new color for the profile itself replaces its pick: this game's Profiles screen (update), or
|
|
129
|
+
// a sibling's through the shared roster (setAll). A pick for a profile missing from the roster is
|
|
130
|
+
// left alone, like the selection's own pruneToRoster leaves stats alone.
|
|
131
|
+
if (profilesActions.update.match(action)) {
|
|
132
|
+
const { id, patch } = action.payload
|
|
133
|
+
const pick = state.profileColors[id]
|
|
134
|
+
return pick && patch.color !== undefined && !sameColor(patch.color, pick.profileColor) ? withoutPick(state, id) : state
|
|
135
|
+
}
|
|
136
|
+
if (profilesActions.setAll.match(action)) {
|
|
137
|
+
return action.payload.reduce((next, profile) => {
|
|
138
|
+
const pick = next.profileColors[profile.id]
|
|
139
|
+
return pick && !sameColor(profile.color, pick.profileColor) ? withoutPick(next, profile.id) : next
|
|
140
|
+
}, state)
|
|
141
|
+
}
|
|
92
142
|
return state
|
|
93
143
|
}
|
|
94
144
|
|
|
95
|
-
return { actions: { setLastCpuColor, setLastGuestColor,
|
|
145
|
+
return { actions: { setLastCpuColor, setLastGuestColor, setProfileColor }, reducer }
|
|
96
146
|
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { AppState } from 'react-native'
|
|
3
3
|
|
|
4
|
-
import { isSharedProfileStoreAvailable, readSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
|
|
4
|
+
import { isSharedProfileStoreAvailable, readSharedProfiles, saveSharedProfiles, subscribeSharedProfiles } from '../sharedProfileStore'
|
|
5
5
|
import { Profile } from '../types'
|
|
6
6
|
|
|
7
7
|
export interface UseSharedProfilesSyncOptions {
|
|
8
8
|
groupId: string
|
|
9
9
|
// Called with a freshly-read shared roster whenever the app returns to the foreground - the
|
|
10
|
-
//
|
|
11
|
-
// profile in a different @tastic app, then switched back to this one."
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// App Group has no cross-process change notification, so this is what catches "edited a
|
|
11
|
+
// profile in a different @tastic app, then switched back to this one." On the web it is also
|
|
12
|
+
// called the moment another tab or game on the same origin saves the roster (the browser's
|
|
13
|
+
// `storage` event, see subscribeSharedProfiles), so two games open side by side don't overwrite
|
|
14
|
+
// each other's edits. Skipped while a syncToShared write is still in flight (see pendingWriteRef
|
|
15
|
+
// below), skipped when the read fails (a failed read is not an empty roster, see
|
|
16
|
+
// readSharedProfiles), and never called at all when the shared store isn't available.
|
|
15
17
|
onRemoteChange: (profiles: Profile[]) => void
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -52,9 +54,13 @@ export function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProf
|
|
|
52
54
|
if (!cancelled && shared !== null) onRemoteChangeRef.current(shared)
|
|
53
55
|
})
|
|
54
56
|
})
|
|
57
|
+
const unsubscribe = subscribeSharedProfiles(groupId, (shared) => {
|
|
58
|
+
if (!cancelled && !pendingWriteRef.current) onRemoteChangeRef.current(shared)
|
|
59
|
+
})
|
|
55
60
|
return () => {
|
|
56
61
|
cancelled = true
|
|
57
62
|
sub.remove()
|
|
63
|
+
unsubscribe()
|
|
58
64
|
}
|
|
59
65
|
}, [groupId])
|
|
60
66
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Platform } from 'react-native'
|
|
2
|
+
|
|
1
3
|
import { isValidProfile } from './profilesValidation'
|
|
2
4
|
import { Profile } from './types'
|
|
3
5
|
|
|
@@ -9,9 +11,9 @@ interface NativeTasticProfile {
|
|
|
9
11
|
// Lazily/optionally resolved - this package has no hard dependency on expo-modules-core (it's a
|
|
10
12
|
// peer, provided by whatever Expo app consumes it), and the native module itself only registers on
|
|
11
13
|
// iOS (see ios/TasticProfileModule.swift and expo-module.config.json's "apple"-only platform list).
|
|
12
|
-
// Guarding both the require and the lookup means Android
|
|
14
|
+
// Guarding both the require and the lookup means Android, and an iOS build that hasn't run
|
|
13
15
|
// `expo prebuild` since adding this package yet, both degrade to "sharing unavailable" instead of a
|
|
14
|
-
// hard crash.
|
|
16
|
+
// hard crash. The web has a store of its own, below.
|
|
15
17
|
let native: NativeTasticProfile | null = null
|
|
16
18
|
try {
|
|
17
19
|
const { requireOptionalNativeModule } = require('expo-modules-core')
|
|
@@ -22,10 +24,73 @@ try {
|
|
|
22
24
|
|
|
23
25
|
const PROFILES_KEY = 'sharedProfiles'
|
|
24
26
|
|
|
27
|
+
// The App Group's suite and key, flattened into one localStorage key: `<groupId>:sharedProfiles`.
|
|
28
|
+
const webKey = (groupId: string, key: string) => `${groupId}:${key}`
|
|
29
|
+
|
|
30
|
+
// The web build's stand-in for the native module. Every game in the group is served from one
|
|
31
|
+
// origin (games.infinitetoken.com/<game>/), so they all see the same localStorage, which plays the
|
|
32
|
+
// App Group's part. It has the native module's shape, so readSharedProfiles and saveSharedProfiles
|
|
33
|
+
// below don't change. Null (sharing unavailable, as before) off the web, and when localStorage
|
|
34
|
+
// can't be written at all: touching it can throw (storage blocked, a sandboxed frame), and a store
|
|
35
|
+
// that took every write and kept none would wipe the roster (a save fails silently, the key stays
|
|
36
|
+
// missing, the next read comes back [] and the app is handed that as its roster). So it writes and
|
|
37
|
+
// removes a probe once, at load. After a write that fails anyway (the storage quota), reads fail
|
|
38
|
+
// until a write succeeds again, so an older roster still in storage can't undo a change the app
|
|
39
|
+
// has already made locally.
|
|
40
|
+
const createWebStore = (): { store: NativeTasticProfile; storage: Storage } | null => {
|
|
41
|
+
if (Platform.OS !== 'web') return null
|
|
42
|
+
try {
|
|
43
|
+
const storage = globalThis.localStorage
|
|
44
|
+
if (!storage) return null
|
|
45
|
+
const probe = webKey('@tastic/profile', 'probe')
|
|
46
|
+
storage.setItem(probe, '1')
|
|
47
|
+
storage.removeItem(probe)
|
|
48
|
+
let lastWriteFailed = false
|
|
49
|
+
return {
|
|
50
|
+
storage,
|
|
51
|
+
store: {
|
|
52
|
+
getSharedJSON: async (groupId, key) => {
|
|
53
|
+
if (lastWriteFailed) throw new Error('The last write to the shared roster failed')
|
|
54
|
+
return storage.getItem(webKey(groupId, key))
|
|
55
|
+
},
|
|
56
|
+
setSharedJSON: async (groupId, key, value) => {
|
|
57
|
+
try {
|
|
58
|
+
storage.setItem(webKey(groupId, key), value)
|
|
59
|
+
lastWriteFailed = false
|
|
60
|
+
} catch (error) {
|
|
61
|
+
lastWriteFailed = true
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const web = native ? null : createWebStore()
|
|
73
|
+
if (web) native = web.store
|
|
74
|
+
|
|
25
75
|
// True once at module load - a consuming app uses this to decide whether to fall back to its own
|
|
26
76
|
// local-only storage for the base roster too (see this package's README's App Group section).
|
|
27
77
|
export const isSharedProfileStoreAvailable = native !== null
|
|
28
78
|
|
|
79
|
+
// A stored roster's JSON as profiles: [] for nothing stored, null for a blob that isn't a readable
|
|
80
|
+
// roster (not JSON, not an array, or entries none of which are valid). Entries that fail
|
|
81
|
+
// isValidProfile are dropped individually rather than invalidating the whole roster.
|
|
82
|
+
const parseRoster = (json: string | null): Profile[] | null => {
|
|
83
|
+
if (!json) return []
|
|
84
|
+
try {
|
|
85
|
+
const parsed: unknown = JSON.parse(json)
|
|
86
|
+
if (!Array.isArray(parsed)) return null
|
|
87
|
+
const profiles = parsed.filter(isValidProfile)
|
|
88
|
+
return parsed.length > 0 && profiles.length === 0 ? null : profiles
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
29
94
|
// Reads the shared base-identity roster out of a given App Group suite, telling a failed read
|
|
30
95
|
// apart from an empty roster. Resolves to [] when nothing has been shared yet (a missing/empty
|
|
31
96
|
// blob) or the store is unavailable, and to null when the store is there but its roster couldn't
|
|
@@ -47,15 +112,7 @@ export async function readSharedProfiles(groupId: string): Promise<Profile[] | n
|
|
|
47
112
|
} catch {
|
|
48
113
|
return null
|
|
49
114
|
}
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const parsed: unknown = JSON.parse(json)
|
|
53
|
-
if (!Array.isArray(parsed)) return null
|
|
54
|
-
const profiles = parsed.filter(isValidProfile)
|
|
55
|
-
return parsed.length > 0 && profiles.length === 0 ? null : profiles
|
|
56
|
-
} catch {
|
|
57
|
-
return null
|
|
58
|
-
}
|
|
115
|
+
return parseRoster(json)
|
|
59
116
|
}
|
|
60
117
|
|
|
61
118
|
// readSharedProfiles with a failed read flattened to []: always resolves to an array, never
|
|
@@ -78,3 +135,22 @@ export async function saveSharedProfiles(groupId: string, profiles: Profile[]):
|
|
|
78
135
|
// Best-effort - see doc comment above.
|
|
79
136
|
}
|
|
80
137
|
}
|
|
138
|
+
|
|
139
|
+
// Calls `listener` with the roster another tab or game on this origin has just saved (the
|
|
140
|
+
// browser's `storage` event, which never fires in the tab that wrote). The same parse as
|
|
141
|
+
// readSharedProfiles; a roster that can't be read is skipped, and so is the key's removal (or a
|
|
142
|
+
// whole-storage clear), so neither can spread a wipe. Returns the unsubscribe. Does nothing on
|
|
143
|
+
// native, where the App Group has no change notification (useSharedProfilesSync re-reads when the
|
|
144
|
+
// app comes back to the foreground instead), and on the web when the store isn't available.
|
|
145
|
+
export function subscribeSharedProfiles(groupId: string, listener: (profiles: Profile[]) => void): () => void {
|
|
146
|
+
if (!web || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return () => {}
|
|
147
|
+
const storageKey = webKey(groupId, PROFILES_KEY)
|
|
148
|
+
const storage = web.storage
|
|
149
|
+
const onStorage = (event: StorageEvent) => {
|
|
150
|
+
if (event.key !== storageKey || event.storageArea !== storage || !event.newValue) return
|
|
151
|
+
const profiles = parseRoster(event.newValue)
|
|
152
|
+
if (profiles !== null) listener(profiles)
|
|
153
|
+
}
|
|
154
|
+
window.addEventListener('storage', onStorage)
|
|
155
|
+
return () => window.removeEventListener('storage', onStorage)
|
|
156
|
+
}
|