@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.
@@ -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 — mirrored locally rather than importing its
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) — this prop is exactly what lets both keep working unchanged.
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 — deliberately
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 — fires once, after this component's
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) — same
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
- // cardBg/titleVariant/colorPreview are forwarded straight through to the internal ProfilesManager.
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 — it's one layer up from ProfilesManager, not a different kind of thing. Deliberately
53
- // still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself — those two
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, cardBg, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps) {
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 — nothing outside this component ever needs it, since this is now the one
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} cardBg={cardBg} titleVariant={titleVariant} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} headerLeft={<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={handleBack} accessibilityLabel='Back' />} />
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 { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
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 — a single emoji can span more than one UTF-16 code unit
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) — built from String.fromCodePoint rather
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 — good enough for telling
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 — clampTag below only needs to know
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 — a profile can have no tag yet, same as it can go without ever entering an
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 — see
49
- // ProfilesManager's own EditRow — reporting its own replacement text as no longer than what it
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 — typing more
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 — an app that extends
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 — not exported from
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 — never synced to the shared App Group
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 — same convention
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 — this stays a plain,
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 — this factory otherwise has no opinion on whether the
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 — always local, never part of the shared App Group roster
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 — same duplication
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 — same convention
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` — the same literal object (e.g.
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' — the literal key every current consumer (AirHockey,
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 — only worth overriding if some future consumer ever mounts
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 — the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
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
- return { ...defaultState, ...state, ...persisted }
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, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
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 — this never touches AsyncStorage itself,
6
- // only the shared App Group store via loadSharedProfiles/saveSharedProfiles). Every @tastic game
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 — seed it from `localFallback` (so
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 — avoids resurrecting a profile that was deleted from another app.
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 loadSharedProfiles(groupId)
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 — this factory otherwise has no opinion on whether the
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 — same
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 — before this factory existed; this is that shape,
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 — a real state-shape migration (and a redux-persist rehydration
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 — same convention
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` — same reasoning, and the same
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' — the literal key every current consumer (AirHockey,
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 — only
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 — the same pattern Snake's own hand-rolled `gameSlice.ts`
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, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
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 — the
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), and never called at all
13
- // when the shared store isn't available.
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
- // — the caller is responsible for its own local persistence in that case, same as before this
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 — that one's for the one-time initial load, this one's for
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 — the foreground-refresh effect below
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 — an app typically passes an inline callback that
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
- loadSharedProfiles(groupId).then((shared) => {
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 — this package has no hard dependency on expo-modules-core (it's a
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 — a consuming app uses this to decide whether to fall back to its own
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. Always resolves to an
30
- // array, never null/throws — an unavailable store, a missing/empty blob, and a corrupt blob all
31
- // read the same as "nothing shared yet." Entries that fail isValidProfile are dropped individually
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 — a host app's own extension fields (a control scheme,
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
- export async function loadSharedProfiles(groupId: string): Promise<Profile[]> {
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
- return Array.isArray(parsed) ? parsed.filter(isValidProfile) : []
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
- // Overwrites the entire shared roster — same whole-blob-write discipline a host app's own
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 — see doc comment above.
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" — deliberately nothing else. An app that needs more (a key/control scheme, a
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 — no wrapper or adapter needed on the app's side.
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