@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/README.md +46 -11
- package/dist/index.d.mts +10 -5
- package/dist/index.d.ts +10 -5
- package/dist/index.js +219 -115
- package/dist/index.mjs +212 -110
- package/package.json +5 -5
- package/src/IdentityChip.tsx +8 -2
- package/src/OptionPicker.tsx +1 -1
- package/src/PickerMenu.tsx +41 -10
- package/src/ProfileColorContext.tsx +30 -8
- package/src/ProfilePicker.tsx +10 -4
- package/src/ProfilesManager.tsx +2 -1
- package/src/SwatchLayer.tsx +28 -0
- package/src/index.ts +2 -2
- package/src/redux/resolveInitialProfiles.ts +3 -2
- package/src/redux/useSharedProfilesSync.ts +12 -6
- package/src/sharedProfileStore.ts +87 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "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.
|
|
72
|
+
"@rific/toaster": "^0.8.2",
|
|
73
73
|
"@tastic/core": "^0.14.0",
|
|
74
|
-
"@tastic/hud": "^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.
|
|
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.
|
|
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",
|
package/src/IdentityChip.tsx
CHANGED
|
@@ -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}
|
package/src/OptionPicker.tsx
CHANGED
|
@@ -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'
|
package/src/PickerMenu.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
{
|
|
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:
|
|
389
|
-
height:
|
|
390
|
-
width:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/ProfilePicker.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
11
|
-
// the entitlement was added): `localFallback` IS the
|
|
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
|
-
//
|
|
11
|
-
// profile in a different @tastic app, then switched back to this one."
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|