@tastic/profile 0.11.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.
- package/LICENSE +21 -0
- package/README.md +87 -63
- package/dist/index.d.mts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +50 -15
- package/dist/index.mjs +48 -15
- package/ios/TasticProfileModule.swift +2 -2
- package/package.json +2 -2
- package/src/ProfileChip.tsx +5 -5
- package/src/ProfilePicker.tsx +31 -31
- package/src/ProfilesManager.tsx +45 -45
- package/src/ProfilesScreen.tsx +8 -8
- 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
|
@@ -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
|