@tastic/profile 0.15.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Player profile identity for local-multiplayer React Native games: a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
5
5
  "keywords": [
6
6
  "react-native",
@@ -69,9 +69,9 @@
69
69
  "@rific/core": "^0.3.1",
70
70
  "@rific/feedback-press": "^0.13.0",
71
71
  "@rific/focus-chain": "^0.4.8",
72
- "@rific/toaster": "^0.8.1",
72
+ "@rific/toaster": "^0.8.2",
73
73
  "@tastic/core": "^0.14.0",
74
- "@tastic/hud": "^0.20.0",
74
+ "@tastic/hud": "^0.23.0",
75
75
  "@testing-library/dom": "^10.4.1",
76
76
  "@testing-library/react": "^16.3.2",
77
77
  "@types/jest": "^30.0.0",
@@ -79,7 +79,7 @@
79
79
  "eslint": "^9.39.4",
80
80
  "eslint-plugin-react-hooks": "^7.1.1",
81
81
  "eslint-plugin-react-native": "^5.0.0",
82
- "expo-modules-core": "^58.0.21",
82
+ "expo-modules-core": "^58.0.22",
83
83
  "jest": "^30.4.2",
84
84
  "jest-environment-jsdom": "^30.4.1",
85
85
  "prettier": "^3.8.3",
@@ -97,7 +97,7 @@
97
97
  "@rific/focus-chain": ">=0.4.0",
98
98
  "@rific/toaster": ">=0.4.0",
99
99
  "@tastic/core": ">=0.1.0",
100
- "@tastic/hud": ">=0.12.0",
100
+ "@tastic/hud": ">=0.23.0",
101
101
  "expo-modules-core": ">=1.0.0",
102
102
  "react": ">=19.0.0",
103
103
  "react-native": ">=0.76.0",
@@ -2,6 +2,9 @@ import { getContrastColor } from '@rific/auto-paper'
2
2
  import { StyleSheet, View } from 'react-native'
3
3
  import { Icon, Text } from 'react-native-paper'
4
4
 
5
+ import { useProfileSwatchRenderer } from './ProfileColorContext'
6
+ import { SwatchLayer } from './SwatchLayer'
7
+
5
8
  // Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) - this
6
9
  // only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
7
10
  // just "does this tag contain a pictograph at all."
@@ -21,8 +24,10 @@ interface Props {
21
24
 
22
25
  // A filled circle with a tag (or an icon) in a contrast-safe color on top: the drawing behind
23
26
  // ProfileChip (a profile's own colors) and ProfilePicker's chip trigger (the live color in effect,
24
- // which may not be any profile's saved one).
27
+ // which may not be any profile's saved one). A ProfileColorProvider `renderSwatch` draws the fill in
28
+ // place of the flat color (a filled chip only); the tag's contrast still comes from `color`.
25
29
  export function IdentityChip({ color, tag, icon, size, filled = true }: Props) {
30
+ const renderSwatch = useProfileSwatchRenderer()
26
31
  const contrastColor = getContrastColor(color)
27
32
  // An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
28
33
  // font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
@@ -34,7 +39,8 @@ export function IdentityChip({ color, tag, icon, size, filled = true }: Props) {
34
39
  // them off there ("D..."); 0.32 fits them, with adjustsFontSizeToFit still the backstop on native.
35
40
  const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : tag && Array.from(tag).length >= 3 ? 0.32 : 0.4)
36
41
  return (
37
- <View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: color }]}>
42
+ <View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && !renderSwatch && { backgroundColor: color }]}>
43
+ {filled && renderSwatch && <SwatchLayer render={renderSwatch} color={color} size={size} />}
38
44
  {tag ? (
39
45
  <Text style={[styles.tag, { color: contrastColor, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
40
46
  {tag}
@@ -1,4 +1,4 @@
1
- import { PopoverHost, PopoverRotation } from '@tastic/hud'
1
+ import type { PopoverHost, PopoverRotation } from '@tastic/hud/chrome'
2
2
  import { type StyleProp, type ViewStyle } from 'react-native'
3
3
 
4
4
  import { IconRow, pickerContentSize, PickerMenu, type ProfilePickerAlignResult, type ProfilePickerColorSection, type ProfilePickerContentSize } from './PickerMenu'
@@ -1,11 +1,14 @@
1
1
  import { defaultColors, FadingScrollView, getContrastColor, type SeedColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import { PopoverBody, PopoverHost, PopoverRotation, useAutoAlign } from '@tastic/hud'
3
+ import type { PopoverHost, PopoverRotation } from '@tastic/hud/chrome'
4
+ import { PopoverBody, useAutoAlign } from '@tastic/hud/chrome'
4
5
  import { ReactNode, RefObject, useState } from 'react'
5
6
  import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
6
7
  import { Icon, Text } from 'react-native-paper'
7
8
 
8
9
  import { IdentityChip } from './IdentityChip'
10
+ import { useProfileSwatchRenderer } from './ProfileColorContext'
11
+ import { SwatchLayer } from './SwatchLayer'
9
12
 
10
13
  // The trigger and popover ProfilePicker and OptionPicker share: a name trigger (or the chip pill),
11
14
  // a bordered menu of rows, and with a `colorSection` a Color row that swaps the menu for a page of
@@ -29,6 +32,10 @@ const CHIP_TRIGGER_PADDING = 6
29
32
  // 20 colors fill 4 full rows), with the same spacing between rows.
30
33
  const SWATCH_SIZE = 28
31
34
  const SWATCH_MIN_GAP = 6
35
+ // The selected swatch's white ring, drawn inside its SWATCH_SIZE.
36
+ const SWATCH_RING_WIDTH = 2
37
+ // The Color row's swatch of the color in effect, in the same 22pt slot as every row's chip.
38
+ const COLOR_ROW_SWATCH_SIZE = 22
32
39
  // The Color row: a plain row's height, or taller with a caption line under the label.
33
40
  const COLOR_ROW_CAPTIONED_HEIGHT = 48
34
41
  const COLOR_LABEL_LINE = 18
@@ -260,12 +267,14 @@ export function IconRow({ icon, label, selected, color, fg, disabled = false, on
260
267
  // to the color page. A divider sets it apart from the selectable rows under it.
261
268
  function ColorRow({ section, fg, dividerColor, onPress }: { section: ProfilePickerColorSection; fg: string; dividerColor: string; onPress: () => void }) {
262
269
  const { value, previewValue, label = 'Color', caption } = section
270
+ const renderSwatch = useProfileSwatchRenderer()
263
271
  const drawn = previewValue ? previewValue(value) : value
264
272
  return (
265
273
  <>
266
274
  <TouchableRipple onPress={onPress} style={[pickerMenuStyles.row, { height: colorRowHeight(caption) }]} accessibilityRole='button' accessibilityLabel={caption ? `${label}, ${caption}` : label}>
267
275
  <View style={pickerMenuStyles.rowInner}>
268
- <View style={[styles.colorRowSwatch, { backgroundColor: drawn }]} />
276
+ {/* A ProfileColorProvider renderSwatch draws it in place of the flat fill. */}
277
+ <View style={[styles.colorRowSwatch, !renderSwatch && { backgroundColor: drawn }]}>{renderSwatch && <SwatchLayer render={renderSwatch} color={drawn} size={COLOR_ROW_SWATCH_SIZE} />}</View>
269
278
  <View style={styles.colorRowText}>
270
279
  <Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
271
280
  {label}
@@ -289,9 +298,12 @@ function ColorRow({ section, fg, dividerColor, onPress }: { section: ProfilePick
289
298
  // back to it (the profile's row shows that color, but matching it to one of two similar swatches by
290
299
  // eye is guesswork). The check takes the dot's place on the swatch in effect, so the dot only shows
291
300
  // while the color in effect is another one. Another seat's color (`takenValue`) is marked with an X
292
- // and disabled, or a swap icon when `allowSwapTaken` lets it be picked.
301
+ // and disabled, or a swap icon when `allowSwapTaken` lets it be picked. A ProfileColorProvider
302
+ // `renderSwatch` draws each swatch in place of its flat fill (inside the selected one's ring), with
303
+ // the mark's contrast still taken from the swatch's own color.
293
304
  function ColorPage({ section, homeColor, fg, onBack }: { section: ProfilePickerColorSection; homeColor?: string; fg: string; onBack: () => void }) {
294
305
  const { value, onChange, previewValue, swatches = defaultColors, label = 'Color', takenValue, allowSwapTaken } = section
306
+ const renderSwatch = useProfileSwatchRenderer()
295
307
  const displayFor = previewValue ?? ((hex: string) => hex)
296
308
  return (
297
309
  <>
@@ -314,11 +326,20 @@ function ColorPage({ section, homeColor, fg, onBack }: { section: ProfilePickerC
314
326
  const swatchDisplay = displayFor(swatch.value)
315
327
  const contrast = getContrastColor(swatchDisplay)
316
328
  const accessibilityLabel = swappable ? `${swatch.label}, swap` : taken ? `${swatch.label}, taken` : isHomeColor ? `${swatch.label}, profile color` : swatch.label
329
+ const mark = selected ? <Icon source='check' size={16} color={contrast} /> : swappable ? <Icon source='swap-horizontal' size={16} color={contrast} /> : taken ? <Icon source='close' size={16} color={contrast} /> : isHomeColor ? <View style={[styles.homeDot, { backgroundColor: contrast }]} /> : <View />
317
330
  // The same swatch as @tastic/hud's InlineColorPicker: a filled circle, the selected one
318
- // ringed and checked, a taken one faded with an X.
331
+ // ringed and checked, a taken one faded with an X. TouchableRipple takes one child, so a
332
+ // drawn swatch and its mark share a face that fills the circle inside the ring.
319
333
  return (
320
- <TouchableRipple key={swatch.value} disabled={taken && !swappable} onPress={() => onChange(swatch.value)} borderless style={[styles.swatch, { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]} accessibilityRole='button' accessibilityLabel={accessibilityLabel} accessibilityState={{ selected, disabled: taken && !swappable }}>
321
- {selected ? <Icon source='check' size={16} color={contrast} /> : swappable ? <Icon source='swap-horizontal' size={16} color={contrast} /> : taken ? <Icon source='close' size={16} color={contrast} /> : isHomeColor ? <View style={[styles.homeDot, { backgroundColor: contrast }]} /> : <View />}
334
+ <TouchableRipple key={swatch.value} disabled={taken && !swappable} onPress={() => onChange(swatch.value)} borderless style={[styles.swatch, !renderSwatch && { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]} accessibilityRole='button' accessibilityLabel={accessibilityLabel} accessibilityState={{ selected, disabled: taken && !swappable }}>
335
+ {renderSwatch ? (
336
+ <View style={styles.swatchFace}>
337
+ <SwatchLayer render={renderSwatch} color={swatchDisplay} size={selected ? SWATCH_SIZE - 2 * SWATCH_RING_WIDTH : SWATCH_SIZE} />
338
+ {mark}
339
+ </View>
340
+ ) : (
341
+ mark
342
+ )}
322
343
  </TouchableRipple>
323
344
  )
324
345
  })}
@@ -385,9 +406,9 @@ const styles = StyleSheet.create({
385
406
  },
386
407
  // The color in effect, in the same 22pt slot as every row's chip.
387
408
  colorRowSwatch: {
388
- borderRadius: 11,
389
- height: 22,
390
- width: 22
409
+ borderRadius: COLOR_ROW_SWATCH_SIZE / 2,
410
+ height: COLOR_ROW_SWATCH_SIZE,
411
+ width: COLOR_ROW_SWATCH_SIZE
391
412
  },
392
413
  colorRowText: {
393
414
  flex: 1
@@ -447,9 +468,19 @@ const styles = StyleSheet.create({
447
468
  justifyContent: 'center',
448
469
  width: SWATCH_SIZE
449
470
  },
471
+ // A drawn swatch (see ColorPage) and its mark: the whole circle inside the selected one's ring.
472
+ swatchFace: {
473
+ alignItems: 'center',
474
+ bottom: 0,
475
+ justifyContent: 'center',
476
+ left: 0,
477
+ position: 'absolute',
478
+ right: 0,
479
+ top: 0
480
+ },
450
481
  swatchSelected: {
451
482
  borderColor: '#ffffff',
452
- borderWidth: 2
483
+ borderWidth: SWATCH_RING_WIDTH
453
484
  },
454
485
  swatchTaken: {
455
486
  opacity: 0.35
@@ -1,27 +1,49 @@
1
- import { createContext, type ReactNode, useContext } from 'react'
1
+ import { createContext, type ReactNode, useContext, useMemo } from 'react'
2
2
 
3
- const ProfileColorContext = createContext<((hex: string) => string) | undefined>(undefined)
3
+ // Draws one color's swatch fill: a circle `size` across, standing in for the flat fill. `color` is the
4
+ // drawn color (after `preview`), and it stays the primary one: the tag, icon, check or dot on top of
5
+ // the swatch takes its contrast from it.
6
+ export type ProfileSwatchRenderer = (color: string, size: number) => ReactNode
7
+
8
+ interface ProfileColorContextValue {
9
+ preview?: (hex: string) => string
10
+ renderSwatch?: ProfileSwatchRenderer
11
+ }
12
+
13
+ const ProfileColorContext = createContext<ProfileColorContextValue>({})
4
14
 
5
15
  interface ProfileColorProviderProps {
6
16
  // Maps a profile's saved color to the color actually drawn for it. Solitaire blends profile colors
7
17
  // toward its card-back palette so they read muted, not as the raw swatch; a context rather than a
8
18
  // module-level setting because that blend depends on light/dark mode.
9
- preview: (hex: string) => string
19
+ preview?: (hex: string) => string
20
+ // Replaces the flat fill of every round swatch this package draws: IdentityChip (ProfileChip and the
21
+ // pickers' chip trigger), the pickers' Color row swatch and their color page swatches. Hangman draws
22
+ // a pie of each color's triad. The renderer is drawn behind the swatch's mark, clipped to the circle.
23
+ // Rows that fill with a color (a selected picker row) stay flat, and so does hud's InlineColorPicker
24
+ // (ProfilesManager's color trigger and swatches), which doesn't read this context.
25
+ renderSwatch?: ProfileSwatchRenderer
10
26
  children: ReactNode
11
27
  }
12
28
 
13
29
  // App-wide display transform for profile colors. Every ProfileChip, ProfilePicker's selected-row
14
30
  // fill and ProfilesManager's color picker previews read it, and so does anything outside this
15
- // package that draws a ProfileChip (@tastic/hud's AchievementUnlockList). Without a provider,
16
- // profile colors draw as saved.
17
- export function ProfileColorProvider({ preview, children }: ProfileColorProviderProps) {
18
- return <ProfileColorContext.Provider value={preview}>{children}</ProfileColorContext.Provider>
31
+ // package that draws a ProfileChip (@tastic/hud's AchievementUnlockList). Without a provider (or
32
+ // with neither prop), profile colors draw as saved, in flat swatches.
33
+ export function ProfileColorProvider({ preview, renderSwatch, children }: ProfileColorProviderProps) {
34
+ const value = useMemo(() => ({ preview, renderSwatch }), [preview, renderSwatch])
35
+ return <ProfileColorContext.Provider value={value}>{children}</ProfileColorContext.Provider>
19
36
  }
20
37
 
21
38
  // The provider's preview, or undefined when there's none (so a caller can tell "no transform"
22
39
  // apart from an identity one, e.g. to fall back to its own colorPreview prop).
23
40
  export function useProfileColorPreview(): ((hex: string) => string) | undefined {
24
- return useContext(ProfileColorContext)
41
+ return useContext(ProfileColorContext).preview
42
+ }
43
+
44
+ // The provider's swatch renderer, or undefined for the flat fill.
45
+ export function useProfileSwatchRenderer(): ProfileSwatchRenderer | undefined {
46
+ return useContext(ProfileColorContext).renderSwatch
25
47
  }
26
48
 
27
49
  // Resolves one saved color to its drawn color.
@@ -1,6 +1,6 @@
1
1
  import { getContrastColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import { PopoverHost, PopoverRotation } from '@tastic/hud'
3
+ import type { PopoverHost, PopoverRotation } from '@tastic/hud/chrome'
4
4
  import { useMemo } from 'react'
5
5
  import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
6
6
  import { Icon, Text } from 'react-native-paper'
@@ -42,6 +42,10 @@ interface ProfilePickerProps<P extends Profile> {
42
42
  // game, where double-selecting one profile would double-count its stats as both winner and
43
43
  // loser) - omit if that doesn't apply.
44
44
  takenId?: string | null
45
+ // Every other seat's profile id, for a lobby with more than two seats: each is disabled in this
46
+ // list the same way as `takenId` (both may be given). This picker's own selection is never taken,
47
+ // so a host can pass every seated id, this seat's included.
48
+ takenIds?: readonly string[]
45
49
  // Tint for the trigger's own label/icon - pass the seat/context's current live color so the
46
50
  // trigger reads as one identity with whatever it's paired with.
47
51
  color: string
@@ -116,7 +120,7 @@ interface ProfilePickerProps<P extends Profile> {
116
120
  // it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven -
117
121
  // no internal profile-storage access of its own, so the host app stays the one place that reads
118
122
  // its own persistence hook.
119
- export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage, colorSection, triggerStyle }: ProfilePickerProps<P>) {
123
+ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, takenIds, color, dark, align, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage, colorSection, triggerStyle }: ProfilePickerProps<P>) {
120
124
  const id = `${idPrefix}-profile`
121
125
  // A selected row fills with the profile's drawn color, the same one its ProfileChip would show.
122
126
  const colorPreview = useProfileColorPreview()
@@ -128,6 +132,8 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
128
132
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
129
133
  // Null row + each saved profile + (if passed) the manage row.
130
134
  const contentSize = getProfilePickerContentSize(profiles.length, !!onManage, colorSection)
135
+ // `takenId` exactly as before; a `takenIds` entry skips this picker's own selection.
136
+ const isTaken = (profileId: string) => profileId === takenId || (profileId !== selectedId && !!takenIds?.includes(profileId))
131
137
 
132
138
  const handleSelectGuest = () => {
133
139
  onSelect(null)
@@ -135,7 +141,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
135
141
  }
136
142
 
137
143
  const handleSelectExisting = (profile: P) => {
138
- if (profile.id === takenId) return
144
+ if (isTaken(profile.id)) return
139
145
  onSelect(profile)
140
146
  host.close()
141
147
  }
@@ -165,7 +171,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
165
171
  <IconRow icon={nullIcon} label={nullLabel} selected={selectedId === null} color={color} fg={fg} onPress={handleSelectGuest} />
166
172
 
167
173
  {sortedProfiles.map((profile) => {
168
- const taken = profile.id === takenId
174
+ const taken = isTaken(profile.id)
169
175
  const selected = selectedId === profile.id
170
176
  const rowColor = colorPreview ? colorPreview(profile.color) : profile.color
171
177
  return (
@@ -2,7 +2,8 @@ import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-
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
8
  import { KeyboardAvoidingView, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
8
9
  import { Icon, Text, TextInput } from 'react-native-paper'
@@ -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,6 +1,6 @@
1
1
  export { getOptionPickerContentSize, OptionPicker, type OptionPickerOption } from './OptionPicker'
2
2
  export { ProfileChip } from './ProfileChip'
3
- export { ProfileColorProvider, useProfileColorPreview, useProfileDisplayColor } from './ProfileColorContext'
3
+ export { ProfileColorProvider, type ProfileSwatchRenderer, useProfileColorPreview, useProfileDisplayColor, useProfileSwatchRenderer } from './ProfileColorContext'
4
4
  export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerColorSection, type ProfilePickerContentSize } from './ProfilePicker'
5
5
  export { type ProfileEditPatch, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
6
6
  export { ProfilesScreen, type ProfilesScreenProps } from './ProfilesScreen'
@@ -12,5 +12,5 @@ export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
12
12
  export { createSeatColorsSlice, type ProfileColorPick, profileSeatColor, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileColorPayload } from './redux/seatColorsSlice'
13
13
  export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
14
14
  export { avoidSeatColorClash } from './seatColorClash'
15
- export { isSharedProfileStoreAvailable, loadSharedProfiles, readSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
15
+ export { isSharedProfileStoreAvailable, loadSharedProfiles, readSharedProfiles, saveSharedProfiles, subscribeSharedProfiles } from './sharedProfileStore'
16
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,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
+ }