@tastic/profile 0.10.0 → 0.12.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.
@@ -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