@tastic/profile 0.14.1 → 0.16.0

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