@tastic/profile 0.11.0 → 0.13.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/LICENSE +21 -0
- package/README.md +91 -70
- package/dist/index.d.mts +6 -5
- package/dist/index.d.ts +6 -5
- package/dist/index.js +55 -55
- package/dist/index.mjs +55 -57
- package/ios/TasticProfileModule.swift +2 -2
- package/package.json +10 -10
- package/src/ProfileChip.tsx +5 -5
- package/src/ProfilePicker.tsx +31 -31
- package/src/ProfilesManager.tsx +55 -107
- package/src/ProfilesScreen.tsx +11 -12
- package/src/index.ts +2 -1
- package/src/profilesValidation.ts +9 -9
- package/src/redux/createActionCreator.ts +1 -1
- package/src/redux/profileExtensionSlice.ts +3 -3
- package/src/redux/profileSelectionSlice.ts +39 -11
- package/src/redux/resolveInitialProfiles.ts +10 -6
- package/src/redux/seatColorsSlice.ts +10 -10
- package/src/redux/useSharedProfilesSync.ts +11 -10
- package/src/seatColorClash.ts +13 -0
- package/src/sharedProfileStore.ts +27 -12
- package/src/types.ts +3 -3
package/src/ProfilesScreen.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { StyleSheet, View } from 'react-native'
|
|
|
6
6
|
import { ProfileEditPatch, ProfilesManager, ProfilesManagerHandle, ProfilesManagerProps } from './ProfilesManager'
|
|
7
7
|
import { Profile } from './types'
|
|
8
8
|
|
|
9
|
-
// react-native-paper's own MD3 Text variant names
|
|
9
|
+
// react-native-paper's own MD3 Text variant names - mirrored locally rather than importing its
|
|
10
10
|
// internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
|
|
11
11
|
// as ProfilesManager's own identical titleVariant prop does.
|
|
12
12
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
@@ -16,28 +16,27 @@ export interface ProfilesScreenProps {
|
|
|
16
16
|
// Same reasoning as ProfilesManager's own defaultColor: this component has no opinion on the
|
|
17
17
|
// host's palette, so it stays required rather than defaulted. Callers already differ here today
|
|
18
18
|
// (most apps pass @rific/auto-paper's defaultColors[0].value; one passes its own local
|
|
19
|
-
// DEFAULT_P1_COLOR constant instead)
|
|
19
|
+
// DEFAULT_P1_COLOR constant instead) - this prop is exactly what lets both keep working unchanged.
|
|
20
20
|
defaultColor: string
|
|
21
|
-
// Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take
|
|
21
|
+
// Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take - deliberately
|
|
22
22
|
// NOT widened to accept a host's own richer CreateProfileInput. A host whose profile shape has
|
|
23
23
|
// extra required fields (e.g. a control/key scheme) composes those in its own closure at the call
|
|
24
24
|
// site, exactly as it already does calling <ProfilesManager> today.
|
|
25
25
|
onCreate: (patch: ProfileEditPatch) => void
|
|
26
26
|
onSave: (id: string, patch: ProfileEditPatch) => void
|
|
27
|
-
// Irreversible, same as ProfilesManager's own onDelete
|
|
27
|
+
// Irreversible, same as ProfilesManager's own onDelete - fires once, after this component's
|
|
28
28
|
// internal confirmation card. If the host tracks anything else keyed by profile id (stats,
|
|
29
29
|
// achievement-unlock history), clear that on its own side inside this callback, same as every
|
|
30
30
|
// current app's `(id) => { deleteProfile(id); removeProfileStats(id) }`.
|
|
31
31
|
onDelete: (id: string) => void
|
|
32
32
|
onBack: () => void
|
|
33
33
|
// Independently optional overrides, each defaulting to the same literal-black/white-by-appearance
|
|
34
|
-
// formula every current caller renders with unchanged (none of them pass these today)
|
|
34
|
+
// formula every current caller renders with unchanged (none of them pass these today) - same
|
|
35
35
|
// convention as BaseStatsScreen's and ProfilesManager's own fg/bg-shaped overrides. fg/fgMuted/
|
|
36
|
-
//
|
|
36
|
+
// titleVariant/colorPreview are forwarded straight through to the internal ProfilesManager.
|
|
37
37
|
fg?: string
|
|
38
38
|
fgMuted?: string
|
|
39
39
|
bg?: string
|
|
40
|
-
cardBg?: string
|
|
41
40
|
titleVariant?: MD3TextVariant
|
|
42
41
|
colorPreview?: (hex: string) => string
|
|
43
42
|
// Forwarded straight through to the internal ProfilesManager - a placement hook for the color swatch popover, for
|
|
@@ -49,13 +48,13 @@ export interface ProfilesScreenProps {
|
|
|
49
48
|
// The routed profiles-roster screen shell every app in this fleet hand-rolls around ProfilesManager
|
|
50
49
|
// today: the container View + theme-derived bg/fg + back button + commit-pending-edit-before-
|
|
51
50
|
// navigate composition. Lives here rather than in @tastic/hud since it has zero generic/non-profile
|
|
52
|
-
// UI content
|
|
53
|
-
// still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself
|
|
51
|
+
// UI content - it's one layer up from ProfilesManager, not a different kind of thing. Deliberately
|
|
52
|
+
// still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself - those two
|
|
54
53
|
// hooks' shapes are genuinely app-specific (different CreateProfileInput/GameStatsContextValue per
|
|
55
54
|
// app), so this stays props-in/callbacks-out, exactly like ProfilesManager itself.
|
|
56
|
-
export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride,
|
|
55
|
+
export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps) {
|
|
57
56
|
const { dark } = useAutoPaperTheme()
|
|
58
|
-
// Owns the ref itself
|
|
57
|
+
// Owns the ref itself - nothing outside this component ever needs it, since this is now the one
|
|
59
58
|
// thing that both renders ProfilesManager AND owns the only navigation trigger next to it.
|
|
60
59
|
const managerRef = useRef<ProfilesManagerHandle>(null)
|
|
61
60
|
// Same literal-black/white-by-appearance formula as ProfilesManager's own fg default, resolved
|
|
@@ -75,7 +74,7 @@ export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDel
|
|
|
75
74
|
|
|
76
75
|
return (
|
|
77
76
|
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
78
|
-
<ProfilesManager ref={managerRef} profiles={profiles} defaultColor={defaultColor} onCreate={onCreate} onSave={onSave} onDelete={onDelete} fg={fg} fgMuted={fgMuted}
|
|
77
|
+
<ProfilesManager ref={managerRef} profiles={profiles} defaultColor={defaultColor} onCreate={onCreate} onSave={onSave} onDelete={onDelete} fg={fg} fgMuted={fgMuted} titleVariant={titleVariant} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} headerLeft={<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={handleBack} accessibilityLabel='Back' />} />
|
|
79
78
|
</View>
|
|
80
79
|
)
|
|
81
80
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,5 +10,6 @@ export { type CreateProfileInput, createProfileRecord, profilesActions, profiles
|
|
|
10
10
|
export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
|
|
11
11
|
export { createSeatColorsSlice, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileOverridePayload } from './redux/seatColorsSlice'
|
|
12
12
|
export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
|
|
13
|
-
export {
|
|
13
|
+
export { avoidSeatColorClash } from './seatColorClash'
|
|
14
|
+
export { isSharedProfileStoreAvailable, loadSharedProfiles, readSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
|
|
14
15
|
export type { Profile } from './types'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Profile } from './types'
|
|
2
2
|
|
|
3
3
|
export const MAX_PROFILE_NAME_LENGTH = 20
|
|
4
|
-
// In code points, not JS string length
|
|
4
|
+
// In code points, not JS string length - a single emoji can span more than one UTF-16 code unit
|
|
5
5
|
// (surrogate pairs, variation selectors), which .length would overcount.
|
|
6
6
|
export const MAX_TAG_LENGTH = 3
|
|
7
7
|
|
|
@@ -11,23 +11,23 @@ function isValidHexColor(value: unknown): value is string {
|
|
|
11
11
|
|
|
12
12
|
// Codepoint 0xFE0F is the variation selector that forces an emoji-style rendering of an otherwise
|
|
13
13
|
// dual-purpose glyph; 0x200D is the zero-width joiner that chains multiple pictographs into one
|
|
14
|
-
// compound emoji (a family, a flag, a skin-tone variant)
|
|
14
|
+
// compound emoji (a family, a flag, a skin-tone variant) - built from String.fromCodePoint rather
|
|
15
15
|
// than embedded directly in a regex literal so this file's own source has no invisible characters
|
|
16
16
|
// in it.
|
|
17
17
|
const VARIATION_SELECTOR = String.fromCodePoint(0xfe0f)
|
|
18
18
|
const ZERO_WIDTH_JOINER = String.fromCodePoint(0x200d)
|
|
19
|
-
// Matches one emoji as a single unit, including a ZWJ-joined compound
|
|
19
|
+
// Matches one emoji as a single unit, including a ZWJ-joined compound - good enough for telling
|
|
20
20
|
// "the user typed an emoji" from "the user typed letters" without needing full Unicode grapheme
|
|
21
21
|
// segmentation (Intl.Segmenter's own Hermes support is inconsistent enough not to lean on here).
|
|
22
22
|
const EMOJI_PATTERN = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, 'u')
|
|
23
23
|
|
|
24
|
-
// Presence check, not the full anchored EMOJI_PATTERN above
|
|
24
|
+
// Presence check, not the full anchored EMOJI_PATTERN above - clampTag below only needs to know
|
|
25
25
|
// whether a just-typed chunk contains a pictograph at all, not whether an entire value is exactly
|
|
26
26
|
// one (possibly ZWJ-joined) emoji. Same distinction @tastic/hud's InlineColorPicker draws for its
|
|
27
27
|
// own font-sizing check.
|
|
28
28
|
const EMOJI_PRESENT = /\p{Extended_Pictographic}/u
|
|
29
29
|
|
|
30
|
-
// Empty is valid too
|
|
30
|
+
// Empty is valid too - a profile can have no tag yet, same as it can go without ever entering an
|
|
31
31
|
// editor's own tag field at all; every place this renders falls back to a generic icon in that case
|
|
32
32
|
// (see ProfileChip).
|
|
33
33
|
export function isValidTag(value: unknown): value is string {
|
|
@@ -45,8 +45,8 @@ export function clampTag(previous: string, next: string): string {
|
|
|
45
45
|
const previousCodepoints = Array.from(previous)
|
|
46
46
|
const nextCodepoints = Array.from(next)
|
|
47
47
|
|
|
48
|
-
// Shrinking (backspace/delete, or a selectTextOnFocus-driven wholesale replacement
|
|
49
|
-
// ProfilesManager's own EditRow
|
|
48
|
+
// Shrinking (backspace/delete, or a selectTextOnFocus-driven wholesale replacement - see
|
|
49
|
+
// ProfilesManager's own EditRow - reporting its own replacement text as no longer than what it
|
|
50
50
|
// overwrote) always passes through untouched: there's nothing to clamp about removing content.
|
|
51
51
|
if (nextCodepoints.length <= previousCodepoints.length) return next
|
|
52
52
|
|
|
@@ -54,7 +54,7 @@ export function clampTag(previous: string, next: string): string {
|
|
|
54
54
|
while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++
|
|
55
55
|
const added = nextCodepoints.slice(sharedPrefixLength).join('')
|
|
56
56
|
|
|
57
|
-
// A tag is either a handful of plain characters or exactly one emoji, never a mix
|
|
57
|
+
// A tag is either a handful of plain characters or exactly one emoji, never a mix - typing more
|
|
58
58
|
// of either kind on top of the other one starts fresh with just what's newly typed, rather than
|
|
59
59
|
// producing a combination isValidTag would reject anyway (an emoji followed by letters, or a
|
|
60
60
|
// second emoji tacked onto the first).
|
|
@@ -63,7 +63,7 @@ export function clampTag(previous: string, next: string): string {
|
|
|
63
63
|
return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// Validates only the fields this package's own `Profile` type declares
|
|
66
|
+
// Validates only the fields this package's own `Profile` type declares - an app that extends
|
|
67
67
|
// Profile with its own fields (a control scheme, anything else) needs its own wrapper that runs
|
|
68
68
|
// this first and then checks its own additions, same as it extends the type itself. See this
|
|
69
69
|
// package's own README for the composition pattern.
|
|
@@ -10,7 +10,7 @@ export type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
|
|
|
10
10
|
|
|
11
11
|
// Same low-level shape @rific/core's createSettingsSlice gives every other slice in the fleet
|
|
12
12
|
// (namespaced type string, .type, .match). Shared internally between profilesSlice.ts and
|
|
13
|
-
// profileExtensionSlice.ts rather than each hand-rolling its own copy
|
|
13
|
+
// profileExtensionSlice.ts rather than each hand-rolling its own copy - not exported from
|
|
14
14
|
// index.ts, since it's an implementation detail of how this package's own reducers are built, not
|
|
15
15
|
// something a consuming app needs.
|
|
16
16
|
export function createActionCreator<P>(type: string): ActionCreator<P> {
|
|
@@ -19,7 +19,7 @@ export interface ProfileExtensionSlice<TExtension> {
|
|
|
19
19
|
|
|
20
20
|
// A per-profile extension is any app-specific field that doesn't belong on the shared base Profile
|
|
21
21
|
// type (a control scheme, a key-binding preference, a difficulty setting, ...) but still needs to
|
|
22
|
-
// travel WITH a specific profile id, entirely locally
|
|
22
|
+
// travel WITH a specific profile id, entirely locally - never synced to the shared App Group
|
|
23
23
|
// roster (a profile created on a sibling @tastic game has no idea this extension exists at all,
|
|
24
24
|
// same as this package's own README describes for any app-specific field). Two real @tastic games
|
|
25
25
|
// (BoxHockey's controlScheme, LightCycles' keyScheme) each hand-rolled an almost-identical
|
|
@@ -28,10 +28,10 @@ export interface ProfileExtensionSlice<TExtension> {
|
|
|
28
28
|
// need a third copy.
|
|
29
29
|
//
|
|
30
30
|
// `namespace` prefixes every action type (e.g. 'profileExtension/controlScheme/set') so an app with
|
|
31
|
-
// more than one independent per-profile extension never collides between them
|
|
31
|
+
// more than one independent per-profile extension never collides between them - same convention
|
|
32
32
|
// @rific/core's createSettingsSlice uses its own namespace argument for.
|
|
33
33
|
//
|
|
34
|
-
// Deliberately NOT auto-cleared when the base `profiles` slice removes an id
|
|
34
|
+
// Deliberately NOT auto-cleared when the base `profiles` slice removes an id - this stays a plain,
|
|
35
35
|
// self-contained reducer rather than importing profilesActions.remove's action type to listen for.
|
|
36
36
|
// An app already dispatches multiple independent actions when deleting a profile (profilesActions.
|
|
37
37
|
// remove, and typically its own local selection-slice cleanup); calling this slice's own `remove`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
2
|
|
|
3
3
|
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
4
|
-
// dependency on the package itself
|
|
4
|
+
// dependency on the package itself - this factory otherwise has no opinion on whether the
|
|
5
5
|
// consuming app uses redux-persist at all, and the string itself is a long-stable part of
|
|
6
6
|
// redux-persist's own public contract (unchanged across every major version). See the reducer's
|
|
7
7
|
// own REHYDRATE case below for why this needs handling at all.
|
|
@@ -25,30 +25,30 @@ export interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
|
25
25
|
reducer: (state: ProfileSelectionState<TSeat> | undefined, action: { type: string; payload?: unknown }) => ProfileSelectionState<TSeat>
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// Which profile each seat last picked
|
|
28
|
+
// Which profile each seat last picked - always local, never part of the shared App Group roster
|
|
29
29
|
// (this package's own profiles slice, see profilesSlice.ts): who's selected on THIS device is
|
|
30
30
|
// naturally a per-app, per-device thing, not something a sibling @tastic game should see or
|
|
31
31
|
// influence. Five @tastic games (AirHockey, BoxHockey, Pong, LightCycles, Snake) each hand-rolled
|
|
32
|
-
// an identical Record<Seat, string | null> reducer before this factory existed
|
|
32
|
+
// an identical Record<Seat, string | null> reducer before this factory existed - same duplication
|
|
33
33
|
// createProfileExtensionSlice generalized away for per-profile extensions, this is that shape for
|
|
34
34
|
// per-seat selection instead.
|
|
35
35
|
//
|
|
36
36
|
// `namespace` prefixes every action type (e.g. 'profileSelection/loadout/select') so an app with
|
|
37
|
-
// more than one independent seat-selection concern never collides between them
|
|
37
|
+
// more than one independent seat-selection concern never collides between them - same convention
|
|
38
38
|
// createProfileExtensionSlice's own namespace argument follows.
|
|
39
39
|
//
|
|
40
40
|
// Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
|
|
41
|
-
// exist at runtime), so callers pass their own `defaultState`
|
|
41
|
+
// exist at runtime), so callers pass their own `defaultState` - the same literal object (e.g.
|
|
42
42
|
// `{ 1: null, 2: null }`) each app already declares as its own `defaultProfileSelectionState`
|
|
43
43
|
// constant today. `clearProfile` walks that state's own keys rather than assuming any particular
|
|
44
44
|
// seat count, so it reproduces the 5 apps' current two-seat behavior without hardcoding "1"/"2".
|
|
45
45
|
//
|
|
46
|
-
// `mountKey` defaults to 'profileSelection'
|
|
46
|
+
// `mountKey` defaults to 'profileSelection' - the literal key every current consumer (AirHockey,
|
|
47
47
|
// BoxHockey, Pong, LightCycles, Snake) already mounts this reducer under in its own
|
|
48
48
|
// combineReducers({...}) call (e.g. `combineReducers({ ..., profileSelection })`). It has to be
|
|
49
49
|
// passed explicitly rather than inferred, since redux-persist's REHYDRATE payload is keyed by
|
|
50
50
|
// whatever name the app's own root reducer happens to use, which this factory has no way to see
|
|
51
|
-
// from inside its own reducer function
|
|
51
|
+
// from inside its own reducer function - only worth overriding if some future consumer ever mounts
|
|
52
52
|
// this under a different key. Note `namespace` above is a different thing: it's the app's own name
|
|
53
53
|
// (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key.
|
|
54
54
|
//
|
|
@@ -56,6 +56,15 @@ export interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
|
56
56
|
// after any profilesActions.setAll (initial reconcile, onRemoteChange), since a sibling app can
|
|
57
57
|
// delete a profile without this app's own deleteProfile ever running. It returns the same state
|
|
58
58
|
// object when nothing changes, so dispatching it on every foreground resume costs no re-render.
|
|
59
|
+
//
|
|
60
|
+
// One profile is never on two seats. `select` picking a profile another seat already has swaps
|
|
61
|
+
// the two: that seat takes this seat's previous pick (a profile, or null for guest), the same rule
|
|
62
|
+
// as @tastic/input's assignSeatScheme. A picker's takenProfileId only hides the other seat's
|
|
63
|
+
// profile while both seats are on screen, so without the swap a 1 Player pick of seat 2's saved
|
|
64
|
+
// profile left both seats on it, and every later 2 Player round recorded a win and a loss for that
|
|
65
|
+
// one profile. A host showing per-seat state tied to the selection (a profile's color override)
|
|
66
|
+
// should reset it for both seats when a pick swaps. REHYDRATE also drops any duplicate saved before
|
|
67
|
+
// this rule existed, keeping the profile on the first seat.
|
|
59
68
|
export function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey = 'profileSelection'): ProfileSelectionSlice<TSeat> {
|
|
60
69
|
const select = createActionCreator<SelectProfilePayload<TSeat>>(`profileSelection/${namespace}/select`)
|
|
61
70
|
const clearProfile = createActionCreator<string>(`profileSelection/${namespace}/clearProfile`)
|
|
@@ -64,16 +73,35 @@ export function createProfileSelectionSlice<TSeat extends string | number>(names
|
|
|
64
73
|
function reducer(state: ProfileSelectionState<TSeat> = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState<TSeat> {
|
|
65
74
|
// redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
|
|
66
75
|
// entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
|
|
67
|
-
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge)
|
|
76
|
+
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) -
|
|
68
77
|
// so a device with a blob persisted before a new seat existed would otherwise rehydrate with
|
|
69
78
|
// that seat's key missing entirely, and every consumer indexes into it (`state[seat]`) with no
|
|
70
79
|
// fallback. Spreading `defaultState` first, then the incoming persisted value, backfills exactly
|
|
71
|
-
// that gap
|
|
80
|
+
// that gap - the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
|
|
72
81
|
if (action.type === REHYDRATE) {
|
|
73
82
|
const persisted = (action.payload as Record<string, Partial<ProfileSelectionState<TSeat>>> | undefined)?.[mountKey]
|
|
74
|
-
|
|
83
|
+
const merged = { ...defaultState, ...state, ...persisted }
|
|
84
|
+
const seen = new Set<string>()
|
|
85
|
+
for (const seat of Object.keys(merged) as TSeat[]) {
|
|
86
|
+
const profileId = merged[seat]
|
|
87
|
+
if (profileId === null) continue
|
|
88
|
+
if (seen.has(profileId)) merged[seat] = null
|
|
89
|
+
else seen.add(profileId)
|
|
90
|
+
}
|
|
91
|
+
return merged
|
|
92
|
+
}
|
|
93
|
+
if (select.match(action)) {
|
|
94
|
+
const { profileId, seat } = action.payload
|
|
95
|
+
const previous = state[seat] ?? null
|
|
96
|
+
const next = { ...state, [seat]: profileId } as ProfileSelectionState<TSeat>
|
|
97
|
+
if (profileId !== null) {
|
|
98
|
+
// Object.keys yields strings, so a numeric seat compares as a string.
|
|
99
|
+
for (const other of Object.keys(state) as TSeat[]) {
|
|
100
|
+
if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return next
|
|
75
104
|
}
|
|
76
|
-
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId } as ProfileSelectionState<TSeat>
|
|
77
105
|
if (clearProfile.match(action)) {
|
|
78
106
|
const next = { ...state }
|
|
79
107
|
for (const seat of Object.keys(state) as TSeat[]) {
|
|
@@ -1,23 +1,27 @@
|
|
|
1
|
-
import { isSharedProfileStoreAvailable,
|
|
1
|
+
import { isSharedProfileStoreAvailable, readSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
|
|
2
2
|
import { Profile } from '../types'
|
|
3
3
|
|
|
4
4
|
// Resolves the roster an app should start with, given whatever it already had saved in its own
|
|
5
|
-
// local fallback storage (however that app stores it
|
|
6
|
-
// only the shared App Group store via
|
|
5
|
+
// local fallback storage (however that app stores it - this never touches AsyncStorage itself,
|
|
6
|
+
// only the shared App Group store via readSharedProfiles/saveSharedProfiles). Every @tastic game
|
|
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
10
|
// - Shared store unavailable (Android/web, or an iOS build that hasn't run `expo prebuild` since
|
|
11
11
|
// the entitlement was added): `localFallback` IS the roster, verbatim.
|
|
12
|
+
// - Shared store available, but its roster couldn't be read (readSharedProfiles' null):
|
|
13
|
+
// `localFallback`, and nothing is written. Seeding here would overwrite every other app's
|
|
14
|
+
// profiles with this app's local snapshot.
|
|
12
15
|
// - Shared store available, and genuinely empty: either a first launch, or the first time THIS
|
|
13
|
-
// app has synced since the App Group entitlement was added
|
|
16
|
+
// app has synced since the App Group entitlement was added - seed it from `localFallback` (so
|
|
14
17
|
// an upgrading user's own profiles don't just vanish), and mirror that seed to the shared store
|
|
15
18
|
// (fire-and-forget; a failed seed write just means the next successful write catches up).
|
|
16
19
|
// - Shared store available and non-empty: shared always wins over a stale local snapshot, never
|
|
17
|
-
// merged with it
|
|
20
|
+
// merged with it - avoids resurrecting a profile that was deleted from another app.
|
|
18
21
|
export async function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]> {
|
|
19
22
|
if (!isSharedProfileStoreAvailable) return localFallback
|
|
20
|
-
const shared = await
|
|
23
|
+
const shared = await readSharedProfiles(groupId)
|
|
24
|
+
if (shared === null) return localFallback
|
|
21
25
|
if (shared.length === 0 && localFallback.length > 0) {
|
|
22
26
|
saveSharedProfiles(groupId, localFallback).catch(() => {})
|
|
23
27
|
return localFallback
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
2
|
|
|
3
3
|
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
4
|
-
// dependency on the package itself
|
|
4
|
+
// dependency on the package itself - this factory otherwise has no opinion on whether the
|
|
5
5
|
// consuming app uses redux-persist at all, and the string itself is a long-stable part of
|
|
6
6
|
// redux-persist's own public contract (unchanged across every major version). See the reducer's
|
|
7
7
|
// own REHYDRATE case below for why this needs handling at all.
|
|
@@ -38,9 +38,9 @@ export interface SeatColorsSlice<TSeat extends string | number> {
|
|
|
38
38
|
// never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and a
|
|
39
39
|
// manual recolor/clash-swap override landing on a seat that currently has a profile selected (null
|
|
40
40
|
// meaning "no override, track that profile's own saved color live"). Four @tastic games (AirHockey,
|
|
41
|
-
// BoxHockey, LightCycles, Pong) each hand-rolled an identical dedicated seatColorsSlice.ts
|
|
41
|
+
// BoxHockey, LightCycles, Pong) each hand-rolled an identical dedicated seatColorsSlice.ts - same
|
|
42
42
|
// three fields, same createSlice-based reducer, differing only in their own seat type's name
|
|
43
|
-
// (Player) and default-color constants
|
|
43
|
+
// (Player) and default-color constants - before this factory existed; this is that shape,
|
|
44
44
|
// generalized over the seat type exactly like createProfileSelectionSlice generalized per-seat
|
|
45
45
|
// profile selection.
|
|
46
46
|
//
|
|
@@ -48,25 +48,25 @@ export interface SeatColorsSlice<TSeat extends string | number> {
|
|
|
48
48
|
// alongside a dozen unrelated persisted settings (wrapEdges, cpuDifficulty, speedTier, ...) rather
|
|
49
49
|
// than in a standalone slice of their own. There's no separate `seatColors` sub-state there for this
|
|
50
50
|
// factory's reducer to own without restructuring gameSlice's top-level shape to nest these three
|
|
51
|
-
// fields under their own key first
|
|
51
|
+
// fields under their own key first - a real state-shape migration (and a redux-persist rehydration
|
|
52
52
|
// concern, see gameSlice's own REHYDRATE extraReducer), not a drop-in swap. This factory
|
|
53
53
|
// intentionally targets the standalone-slice shape the other four apps already have; Snake staying
|
|
54
54
|
// hand-rolled inside gameSlice is a deliberate scope call for this pass, not an oversight.
|
|
55
55
|
//
|
|
56
56
|
// `namespace` prefixes every action type (e.g. 'seatColors/loadout/setLastGuestColor') so an app
|
|
57
|
-
// with more than one independent seat-colors concern never collides between them
|
|
57
|
+
// with more than one independent seat-colors concern never collides between them - same convention
|
|
58
58
|
// createProfileSelectionSlice's and createProfileExtensionSlice's own namespace arguments follow.
|
|
59
59
|
//
|
|
60
60
|
// Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
|
|
61
|
-
// exist at runtime), so callers pass their own `defaultState`
|
|
61
|
+
// exist at runtime), so callers pass their own `defaultState` - same reasoning, and the same
|
|
62
62
|
// {1: ..., 2: ...}-shaped literal each app already declares as its own defaultSeatColorsState
|
|
63
63
|
// constant today, as createProfileSelectionSlice's own defaultState parameter.
|
|
64
64
|
//
|
|
65
|
-
// `mountKey` defaults to 'seatColors'
|
|
65
|
+
// `mountKey` defaults to 'seatColors' - the literal key every current consumer (AirHockey,
|
|
66
66
|
// BoxHockey, Pong) already mounts this reducer under in its own combineReducers({...}) call (e.g.
|
|
67
67
|
// `combineReducers({ ..., seatColors })`). It has to be passed explicitly rather than inferred,
|
|
68
68
|
// since redux-persist's REHYDRATE payload is keyed by whatever name the app's own root reducer
|
|
69
|
-
// happens to use, which this factory has no way to see from inside its own reducer function
|
|
69
|
+
// happens to use, which this factory has no way to see from inside its own reducer function - only
|
|
70
70
|
// worth overriding if some future consumer ever mounts this under a different key.
|
|
71
71
|
export function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey = 'seatColors'): SeatColorsSlice<TSeat> {
|
|
72
72
|
const setLastCpuColor = createActionCreator<string>(`seatColors/${namespace}/setLastCpuColor`)
|
|
@@ -76,11 +76,11 @@ export function createSeatColorsSlice<TSeat extends string | number>(namespace:
|
|
|
76
76
|
function reducer(state: SeatColorsState<TSeat> = defaultState, action: { type: string; payload?: unknown }): SeatColorsState<TSeat> {
|
|
77
77
|
// redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
|
|
78
78
|
// entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
|
|
79
|
-
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge)
|
|
79
|
+
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) -
|
|
80
80
|
// so a device with a blob persisted before `profileOverride` existed would otherwise rehydrate
|
|
81
81
|
// with `state.profileOverride === undefined`, and every consumer indexes into it (`profileOverride[seat]`)
|
|
82
82
|
// with no fallback, throwing. Spreading `defaultState` first, then the incoming persisted
|
|
83
|
-
// value, backfills exactly that gap
|
|
83
|
+
// value, backfills exactly that gap - the same pattern Snake's own hand-rolled `gameSlice.ts`
|
|
84
84
|
// already uses for its equivalent (non-factory) fields via its own REHYDRATE extraReducer.
|
|
85
85
|
if (action.type === REHYDRATE) {
|
|
86
86
|
const persisted = (action.payload as Record<string, Partial<SeatColorsState<TSeat>>> | undefined)?.[mountKey]
|
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { AppState } from 'react-native'
|
|
3
3
|
|
|
4
|
-
import { isSharedProfileStoreAvailable,
|
|
4
|
+
import { isSharedProfileStoreAvailable, readSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
|
|
5
5
|
import { Profile } from '../types'
|
|
6
6
|
|
|
7
7
|
export interface UseSharedProfilesSyncOptions {
|
|
8
8
|
groupId: string
|
|
9
|
-
// Called with a freshly-read shared roster whenever the app returns to the foreground
|
|
9
|
+
// Called with a freshly-read shared roster whenever the app returns to the foreground - the
|
|
10
10
|
// shared store has no cross-process change notification, so this is what catches "edited a
|
|
11
11
|
// profile in a different @tastic app, then switched back to this one." Skipped while a
|
|
12
|
-
// syncToShared write is still in flight (see pendingWriteRef below),
|
|
13
|
-
//
|
|
12
|
+
// syncToShared write is still in flight (see pendingWriteRef below), skipped when the read fails
|
|
13
|
+
// (a failed read is not an empty roster, see readSharedProfiles), and never called at all when
|
|
14
|
+
// the shared store isn't available.
|
|
14
15
|
onRemoteChange: (profiles: Profile[]) => void
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface UseSharedProfilesSyncResult {
|
|
18
19
|
// Call right after applying a roster change locally (dispatching profilesActions.setAll/add/
|
|
19
20
|
// update/remove) to mirror it to the shared store. No-ops when the shared store isn't available
|
|
20
|
-
//
|
|
21
|
+
// - the caller is responsible for its own local persistence in that case, same as before this
|
|
21
22
|
// hook existed. Fire-and-forget: never throws, never awaited by this hook itself.
|
|
22
23
|
syncToShared: (next: Profile[]) => void
|
|
23
24
|
}
|
|
@@ -25,16 +26,16 @@ export interface UseSharedProfilesSyncResult {
|
|
|
25
26
|
// Bundles the two things every @tastic game needs around the shared App Group roster once
|
|
26
27
|
// `profiles` lives in its own Redux store: mirroring local writes out to the shared store, and
|
|
27
28
|
// picking up remote writes a sibling app made while this one was backgrounded. Deliberately
|
|
28
|
-
// separate from resolveInitialProfiles
|
|
29
|
+
// separate from resolveInitialProfiles - that one's for the one-time initial load, this one's for
|
|
29
30
|
// the app's whole lifetime after that.
|
|
30
31
|
export function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult {
|
|
31
|
-
// True for the duration of an in-flight syncToShared write
|
|
32
|
+
// True for the duration of an in-flight syncToShared write - the foreground-refresh effect below
|
|
32
33
|
// skips a refresh while this is true, rather than risk reading back a pre-write snapshot and
|
|
33
34
|
// handing the caller an onRemoteChange that reverts the write still landing. A ref, not state:
|
|
34
35
|
// read from inside the AppState listener's closure, which is only created once per subscription
|
|
35
36
|
// and would otherwise see whatever this was at mount time, forever.
|
|
36
37
|
const pendingWriteRef = useRef(false)
|
|
37
|
-
// Same reasoning, for onRemoteChange itself
|
|
38
|
+
// Same reasoning, for onRemoteChange itself - an app typically passes an inline callback that
|
|
38
39
|
// closes over its own latest dispatch/state, so the listener must always call the current one,
|
|
39
40
|
// not whichever was in scope when the effect below first subscribed.
|
|
40
41
|
const onRemoteChangeRef = useRef(onRemoteChange)
|
|
@@ -47,8 +48,8 @@ export function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProf
|
|
|
47
48
|
let cancelled = false
|
|
48
49
|
const sub = AppState.addEventListener('change', (next) => {
|
|
49
50
|
if (next !== 'active' || pendingWriteRef.current) return
|
|
50
|
-
|
|
51
|
-
if (!cancelled) onRemoteChangeRef.current(shared)
|
|
51
|
+
readSharedProfiles(groupId).then((shared) => {
|
|
52
|
+
if (!cancelled && shared !== null) onRemoteChangeRef.current(shared)
|
|
52
53
|
})
|
|
53
54
|
})
|
|
54
55
|
return () => {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Seat 2's color, moved off seat 1's when the two resolve to the same color. Each seat's color
|
|
2
|
+
// comes from its own source (a selected profile's saved color, or a remembered guest/CPU color, see
|
|
3
|
+
// createSeatColorsSlice), and picking a profile has no swap step the way a manual recolor does, so
|
|
4
|
+
// a profile saved as the other seat's color would otherwise give both seats the same color. On a
|
|
5
|
+
// clash, seat 2 takes seat 1's default color, or seat 2's default when seat 1 is already on its own
|
|
6
|
+
// default. Colors compare case-insensitively. The result is for display, not for persisting: call
|
|
7
|
+
// it wherever the two seats' colors are resolved, so a guest's or the CPU's remembered color is
|
|
8
|
+
// left alone.
|
|
9
|
+
export function avoidSeatColorClash(p1Color: string, p2Color: string, defaults: readonly [p1Default: string, p2Default: string]): string {
|
|
10
|
+
if (p2Color.toLowerCase() !== p1Color.toLowerCase()) return p2Color
|
|
11
|
+
const [p1Default, p2Default] = defaults
|
|
12
|
+
return p2Color.toLowerCase() === p1Default.toLowerCase() ? p2Default : p1Default
|
|
13
|
+
}
|
|
@@ -6,7 +6,7 @@ interface NativeTasticProfile {
|
|
|
6
6
|
setSharedJSON(groupId: string, key: string, value: string): Promise<void>
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
// Lazily/optionally resolved
|
|
9
|
+
// Lazily/optionally resolved - this package has no hard dependency on expo-modules-core (it's a
|
|
10
10
|
// peer, provided by whatever Expo app consumes it), and the native module itself only registers on
|
|
11
11
|
// iOS (see ios/TasticProfileModule.swift and expo-module.config.json's "apple"-only platform list).
|
|
12
12
|
// Guarding both the require and the lookup means Android/web, and an iOS build that hasn't run
|
|
@@ -22,35 +22,50 @@ try {
|
|
|
22
22
|
|
|
23
23
|
const PROFILES_KEY = 'sharedProfiles'
|
|
24
24
|
|
|
25
|
-
// True once at module load
|
|
25
|
+
// True once at module load - a consuming app uses this to decide whether to fall back to its own
|
|
26
26
|
// local-only storage for the base roster too (see this package's README's App Group section).
|
|
27
27
|
export const isSharedProfileStoreAvailable = native !== null
|
|
28
28
|
|
|
29
|
-
// Reads the shared base-identity roster out of a given App Group suite
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// Reads the shared base-identity roster out of a given App Group suite, telling a failed read
|
|
30
|
+
// apart from an empty roster. Resolves to [] when nothing has been shared yet (a missing/empty
|
|
31
|
+
// blob) or the store is unavailable, and to null when the store is there but its roster couldn't
|
|
32
|
+
// be read: the native call threw, the blob isn't JSON or isn't an array, or it held entries and
|
|
33
|
+
// none of them were valid. Never throws. Entries that fail isValidProfile are dropped individually
|
|
32
34
|
// rather than invalidating the whole roster, since the base fields validated here are the ONLY
|
|
33
|
-
// thing this store is a source of truth for
|
|
35
|
+
// thing this store is a source of truth for - a host app's own extension fields (a control scheme,
|
|
34
36
|
// anything else) never travel through it at all; see the README for the base/extension split this
|
|
35
37
|
// is meant to be used with.
|
|
36
|
-
|
|
38
|
+
//
|
|
39
|
+
// A caller that replaces its roster with what it reads, or writes the roster back afterward, must
|
|
40
|
+
// skip a null: taken as an empty roster, a failed read empties the app's roster, and its next
|
|
41
|
+
// write then deletes every profile from the shared store, for every app in the group.
|
|
42
|
+
export async function readSharedProfiles(groupId: string): Promise<Profile[] | null> {
|
|
37
43
|
if (!native) return []
|
|
38
44
|
let json: string | null = null
|
|
39
45
|
try {
|
|
40
46
|
json = await native.getSharedJSON(groupId, PROFILES_KEY)
|
|
41
47
|
} catch {
|
|
42
|
-
return
|
|
48
|
+
return null
|
|
43
49
|
}
|
|
44
50
|
if (!json) return []
|
|
45
51
|
try {
|
|
46
52
|
const parsed: unknown = JSON.parse(json)
|
|
47
|
-
|
|
53
|
+
if (!Array.isArray(parsed)) return null
|
|
54
|
+
const profiles = parsed.filter(isValidProfile)
|
|
55
|
+
return parsed.length > 0 && profiles.length === 0 ? null : profiles
|
|
48
56
|
} catch {
|
|
49
|
-
return
|
|
57
|
+
return null
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
//
|
|
61
|
+
// readSharedProfiles with a failed read flattened to []: always resolves to an array, never
|
|
62
|
+
// null/throws. Kept unchanged for apps that call it directly; prefer readSharedProfiles anywhere a
|
|
63
|
+
// read can replace the roster or lead to a write (see its doc above).
|
|
64
|
+
export async function loadSharedProfiles(groupId: string): Promise<Profile[]> {
|
|
65
|
+
return (await readSharedProfiles(groupId)) ?? []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Overwrites the entire shared roster - same whole-blob-write discipline a host app's own
|
|
54
69
|
// AsyncStorage-backed profile store already uses, not an incremental per-profile update. A write
|
|
55
70
|
// failure (store unavailable, or some other native error) is swallowed rather than thrown: the
|
|
56
71
|
// caller's own local state is already the source of truth for what just changed, and this is a
|
|
@@ -60,6 +75,6 @@ export async function saveSharedProfiles(groupId: string, profiles: Profile[]):
|
|
|
60
75
|
try {
|
|
61
76
|
await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles))
|
|
62
77
|
} catch {
|
|
63
|
-
// Best-effort
|
|
78
|
+
// Best-effort - see doc comment above.
|
|
64
79
|
}
|
|
65
80
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// A saved player identity: just enough for a picker/roster UI to show and let someone select "who
|
|
2
|
-
// am I this round"
|
|
2
|
+
// am I this round" - deliberately nothing else. An app that needs more (a key/control scheme, a
|
|
3
3
|
// stats bucket, anything else keyed by profile id) extends this shape on its own, in its own
|
|
4
4
|
// storage layer, and structural typing carries it straight through every component here that only
|
|
5
|
-
// ever reads these five fields
|
|
5
|
+
// ever reads these five fields - no wrapper or adapter needed on the app's side.
|
|
6
6
|
export interface Profile {
|
|
7
7
|
id: string
|
|
8
8
|
name: string
|
|
9
9
|
color: string
|
|
10
|
-
// User-typed identity mark shown on a seat's own color trigger and in the profile picker/roster
|
|
10
|
+
// User-typed identity mark shown on a seat's own color trigger and in the profile picker/roster -
|
|
11
11
|
// one emoji, or up to MAX_TAG_LENGTH plain characters (see profilesValidation.ts's isValidTag).
|
|
12
12
|
// Can be empty (no tag yet), which just falls back to a generic icon wherever it's shown.
|
|
13
13
|
tag: string
|