@tastic/profile 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -63
- package/dist/index.d.mts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +50 -15
- package/dist/index.mjs +48 -15
- package/ios/TasticProfileModule.swift +2 -2
- package/package.json +2 -2
- package/src/ProfileChip.tsx +5 -5
- package/src/ProfilePicker.tsx +31 -31
- package/src/ProfilesManager.tsx +45 -45
- package/src/ProfilesScreen.tsx +8 -8
- package/src/index.ts +2 -1
- package/src/profilesValidation.ts +9 -9
- package/src/redux/createActionCreator.ts +1 -1
- package/src/redux/profileExtensionSlice.ts +3 -3
- package/src/redux/profileSelectionSlice.ts +39 -11
- package/src/redux/resolveInitialProfiles.ts +10 -6
- package/src/redux/seatColorsSlice.ts +10 -10
- package/src/redux/useSharedProfilesSync.ts +11 -10
- package/src/seatColorClash.ts +13 -0
- package/src/sharedProfileStore.ts +27 -12
- package/src/types.ts +3 -3
package/src/ProfilesManager.tsx
CHANGED
|
@@ -14,17 +14,17 @@ import { type ProfilePickerAlignResult } from './ProfilePicker'
|
|
|
14
14
|
import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
|
|
15
15
|
import { Profile } from './types'
|
|
16
16
|
|
|
17
|
-
// Shared by the New Profile row's own dashed circle and every profile row's own ProfileChip
|
|
17
|
+
// Shared by the New Profile row's own dashed circle and every profile row's own ProfileChip - one
|
|
18
18
|
// size for every circle on this screen, rather than each picking its own.
|
|
19
19
|
const CHIP_SIZE = 40
|
|
20
20
|
|
|
21
|
-
// react-native-paper's own MD3 Text variant names
|
|
21
|
+
// react-native-paper's own MD3 Text variant names - mirrored locally rather than importing its
|
|
22
22
|
// internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
|
|
23
23
|
// as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
|
|
24
24
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
25
25
|
|
|
26
|
-
// Exported (not just used internally) so a host composing a screen around this component
|
|
27
|
-
// @tastic/profile's own ProfilesScreen
|
|
26
|
+
// Exported (not just used internally) so a host composing a screen around this component - e.g.
|
|
27
|
+
// @tastic/profile's own ProfilesScreen - can reference this exact shape in its own onCreate/onSave
|
|
28
28
|
// prop types without redeclaring it.
|
|
29
29
|
export interface ProfileEditPatch {
|
|
30
30
|
name: string
|
|
@@ -33,36 +33,36 @@ export interface ProfileEditPatch {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export interface ProfilesManagerHandle {
|
|
36
|
-
// Flushes whatever row is currently being edited
|
|
36
|
+
// Flushes whatever row is currently being edited - the same thing this component's own unmount
|
|
37
37
|
// effect does, but callable explicitly. Exists because "unmount" isn't a reliable "the user is
|
|
38
38
|
// navigating away" signal on every host: React Navigation's web renderer keeps a popped screen
|
|
39
39
|
// mounted-but-hidden instead of unmounting it, so a host relying only on the unmount fallback
|
|
40
40
|
// silently loses an in-progress edit on `router.back()` there. A host whose router doesn't
|
|
41
41
|
// genuinely unmount this component on navigation should hold a ref and call this right before
|
|
42
42
|
// navigating away (e.g. in its own back-button handler). Safe to call any time, including when
|
|
43
|
-
// nothing is being edited
|
|
43
|
+
// nothing is being edited - a no-op then, same as the unmount effect already is.
|
|
44
44
|
commitPendingEdit: () => void
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface ProfilesManagerProps {
|
|
48
48
|
profiles: Profile[]
|
|
49
|
-
// The color a brand-new profile's draft starts on
|
|
49
|
+
// The color a brand-new profile's draft starts on - this component doesn't have (and shouldn't
|
|
50
50
|
// invent) an opinion about the host app's own palette, so this is required rather than defaulted.
|
|
51
51
|
defaultColor: string
|
|
52
52
|
onCreate: (input: ProfileEditPatch) => void
|
|
53
53
|
onSave: (id: string, patch: ProfileEditPatch) => void
|
|
54
|
-
// Irreversible
|
|
54
|
+
// Irreversible - this only fires after this component's own confirmation card, not on the first
|
|
55
55
|
// tap. If the host app tracks anything else keyed by profile id (stats, history), clear that on
|
|
56
56
|
// its own side when this fires.
|
|
57
57
|
onDelete: (id: string) => void
|
|
58
|
-
// Rendered inline, before the "Profiles" title, in this component's own header row
|
|
58
|
+
// Rendered inline, before the "Profiles" title, in this component's own header row - a plain
|
|
59
59
|
// rendering slot, not an onBack-style callback: this component doesn't know or care that it's a
|
|
60
60
|
// back button, only that the host wants something shown there. Omit to hide the slot.
|
|
61
61
|
headerLeft?: ReactNode
|
|
62
|
-
// React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed)
|
|
62
|
+
// React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed) - see
|
|
63
63
|
// ProfilesManagerHandle's own doc for why a host may need this.
|
|
64
64
|
ref?: Ref<ProfilesManagerHandle>
|
|
65
|
-
// Independently optional overrides for this screen's text/card colors
|
|
65
|
+
// Independently optional overrides for this screen's text/card colors - each defaults to the same
|
|
66
66
|
// literal-black/white-by-appearance formula as before when omitted, so every existing caller
|
|
67
67
|
// (none of which pass these) renders identically to before. Same convention as @tastic/hud's
|
|
68
68
|
// BaseStatsScreen fg/cardBg overrides, for a caller with its own app-wide chrome palette instead
|
|
@@ -74,10 +74,10 @@ export interface ProfilesManagerProps {
|
|
|
74
74
|
fgMuted?: string
|
|
75
75
|
cardBg?: string
|
|
76
76
|
// 'headlineSmall' (this screen's own default, below), matching BaseStatsScreen's own settled
|
|
77
|
-
// default and the reasoning behind it
|
|
77
|
+
// default and the reasoning behind it - this title sits next to a small headerLeft back-button
|
|
78
78
|
// glyph the same way, and the previous hardcoded 'displaySmall' read as oversized there too.
|
|
79
79
|
titleVariant?: MD3TextVariant
|
|
80
|
-
// Transforms the color swatch grid's own preview per-swatch
|
|
80
|
+
// Transforms the color swatch grid's own preview per-swatch - forwarded straight to the internal
|
|
81
81
|
// InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
|
|
82
82
|
// color value untouched; only what's rendered changes. Defaults to ProfileColorProvider's preview.
|
|
83
83
|
colorPreview?: (hex: string) => string
|
|
@@ -97,7 +97,7 @@ export interface ProfilesManagerProps {
|
|
|
97
97
|
|
|
98
98
|
type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
|
|
99
99
|
|
|
100
|
-
// First letter of the name, uppercased
|
|
100
|
+
// First letter of the name, uppercased - the tag's own default for as long as it hasn't been typed
|
|
101
101
|
// into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
|
|
102
102
|
// nothing into the tag field yourself would leave it.
|
|
103
103
|
function deriveTag(name: string): string {
|
|
@@ -105,14 +105,14 @@ function deriveTag(name: string): string {
|
|
|
105
105
|
return trimmed ? trimmed[0].toUpperCase() : ''
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// The profile roster itself
|
|
109
|
-
// you tapped
|
|
108
|
+
// The profile roster itself - create/rename/recolor/retag/delete, editing inline right in the row
|
|
109
|
+
// you tapped - with no navigation of its own: just data in (`profiles`) and callbacks out, plus
|
|
110
110
|
// this file's own internal edit-draft state. Deliberately router-agnostic (an `onBack`-style prop
|
|
111
111
|
// would still assume *some* host owns navigation; this component doesn't even go that far), so any
|
|
112
112
|
// consuming screen just renders it as its body inside its own routed wrapper (back button, native
|
|
113
113
|
// push/pop transition, whatever persistence hook backs `profiles`/`onCreate`/`onSave`/`onDelete`).
|
|
114
114
|
// Staying router-agnostic means this component can't know for itself whether "navigating away"
|
|
115
|
-
// really unmounts it on a given host's router
|
|
115
|
+
// really unmounts it on a given host's router - see ProfilesManagerHandle's own doc for the
|
|
116
116
|
// react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
|
|
117
117
|
// instead of relying on the unmount fallback alone.
|
|
118
118
|
export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps) {
|
|
@@ -130,35 +130,35 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
130
130
|
const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
|
|
131
131
|
const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
|
|
132
132
|
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
133
|
-
// A contained neutral, not an outlined one
|
|
133
|
+
// A contained neutral, not an outlined one - reads as "the other button" next to Delete's solid
|
|
134
134
|
// danger fill, rather than looking unfinished/secondary beside it.
|
|
135
135
|
const cancelBg = dark ? '#2A2A2A' : '#E0E0E0'
|
|
136
136
|
const cancelFg = dark ? '#FFFFFF' : '#000000'
|
|
137
137
|
|
|
138
|
-
// 'new' clears back to null once a commit resolves
|
|
138
|
+
// 'new' clears back to null once a commit resolves - there's only ever one row open at a time.
|
|
139
139
|
const [editingId, setEditingId] = useState<string | 'new' | null>(null)
|
|
140
140
|
const [draftName, setDraftName] = useState('')
|
|
141
141
|
const [draftColor, setDraftColor] = useState(defaultColor)
|
|
142
142
|
const [draftTag, setDraftTag] = useState('')
|
|
143
|
-
// Whether the tag has been typed into directly this session
|
|
143
|
+
// Whether the tag has been typed into directly this session - once true, editing the name stops
|
|
144
144
|
// overwriting it (see deriveTag's own doc). Starts true for an existing profile that already has
|
|
145
|
-
// a tag (don't clobber a deliberate choice just because the name changed), false otherwise
|
|
145
|
+
// a tag (don't clobber a deliberate choice just because the name changed), false otherwise - a
|
|
146
146
|
// brand new profile, or one that's never had a tag set at all.
|
|
147
147
|
const [tagManuallySet, setTagManuallySet] = useState(false)
|
|
148
148
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
|
149
149
|
|
|
150
|
-
// Commits whichever row is currently open, using its own latest draft state, then closes it
|
|
150
|
+
// Commits whichever row is currently open, using its own latest draft state, then closes it -
|
|
151
151
|
// always closes, even when there's nothing valid to save (an empty name just discards the draft
|
|
152
152
|
// rather than leaving the row stuck open). This is the only way a row ever closes: submitting the
|
|
153
|
-
// name field (see EditRow's own focus-chain wiring
|
|
153
|
+
// name field (see EditRow's own focus-chain wiring - tag submits to name, name submits this),
|
|
154
154
|
// switching to a different row (see startEdit, which calls this before opening a new one), or
|
|
155
|
-
// leaving the screen entirely (see the unmount effect below)
|
|
155
|
+
// leaving the screen entirely (see the unmount effect below) - no explicit save/cancel buttons,
|
|
156
156
|
// just scoped to the whole row instead of one field, since there's more than one field to touch.
|
|
157
157
|
const commitEdit = useCallback(() => {
|
|
158
158
|
const name = draftName.trim()
|
|
159
159
|
if (editingId && name) {
|
|
160
160
|
const tagValid = isValidTag(draftTag)
|
|
161
|
-
// Surfaced here, not just the field's own live `error` outline
|
|
161
|
+
// Surfaced here, not just the field's own live `error` outline - that outline disappears the
|
|
162
162
|
// moment this row closes, so without this a still-invalid tag would just silently vanish
|
|
163
163
|
// (saved as empty) with no explanation of why it didn't stick.
|
|
164
164
|
if (draftTag && !tagValid) showErrorToast('Invalid tag', 'Use up to 3 letters or a single emoji')
|
|
@@ -169,11 +169,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
169
169
|
setEditingId(null)
|
|
170
170
|
}, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast])
|
|
171
171
|
|
|
172
|
-
// Flushes whatever's currently being edited if this whole screen goes away mid-edit
|
|
172
|
+
// Flushes whatever's currently being edited if this whole screen goes away mid-edit - a hardware
|
|
173
173
|
// back gesture or swipe-back can bypass a host screen's own back button (which should already
|
|
174
174
|
// call commitEdit before navigating away) but still unmounts this component the same as any other
|
|
175
175
|
// exit does. Reads the always-current commitEdit through a ref rather than depending on it
|
|
176
|
-
// directly in the effect
|
|
176
|
+
// directly in the effect - one that re-ran (and re-cleaned-up) on every keystroke would commit on
|
|
177
177
|
// every keystroke too, not just on a genuine unmount.
|
|
178
178
|
const commitEditRef = useRef(commitEdit)
|
|
179
179
|
useEffect(() => {
|
|
@@ -181,7 +181,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
181
181
|
})
|
|
182
182
|
useEffect(() => () => commitEditRef.current(), [])
|
|
183
183
|
// The explicit escape hatch for hosts whose "navigate away" doesn't genuinely unmount this
|
|
184
|
-
// component
|
|
184
|
+
// component - see ProfilesManagerHandle's own doc. `[]` deps: the handle object itself never
|
|
185
185
|
// needs to change identity, since it always calls through the ref to whatever commitEdit is
|
|
186
186
|
// current at call time, the same indirection the unmount effect above already relies on.
|
|
187
187
|
useImperativeHandle(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), [])
|
|
@@ -213,8 +213,8 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
213
213
|
[tagManuallySet]
|
|
214
214
|
)
|
|
215
215
|
|
|
216
|
-
// The functional setDraftTag form
|
|
217
|
-
// rather than closing over the `draftTag` variable
|
|
216
|
+
// The functional setDraftTag form - reading `previous` from React's own latest-applied state
|
|
217
|
+
// rather than closing over the `draftTag` variable - matters here, not just style: fast/rapid
|
|
218
218
|
// keystrokes can fire their onChangeText calls before a re-render lands, batched into the same
|
|
219
219
|
// update, and clamping every one of them against the same stale `draftTag` (captured once at
|
|
220
220
|
// render time) would let the later ones silently clobber what the earlier ones already clamped
|
|
@@ -233,7 +233,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
233
233
|
setConfirmDeleteId(null)
|
|
234
234
|
}, [confirmDeleteId, editingId, onDelete])
|
|
235
235
|
|
|
236
|
-
// Alphabetical
|
|
236
|
+
// Alphabetical - this list has no other natural order (creation order isn't meaningful to look
|
|
237
237
|
// something up by), and it's the one that actually stays usable once there are more than a
|
|
238
238
|
// handful of profiles on one shared device.
|
|
239
239
|
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
|
|
@@ -279,7 +279,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
279
279
|
return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} />
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
// No delete affordance at rest
|
|
282
|
+
// No delete affordance at rest - tap the row to start editing, which is the one place
|
|
283
283
|
// delete shows (see EditRow): a deliberate extra step before a destructive action is
|
|
284
284
|
// even reachable, not just before it's confirmed.
|
|
285
285
|
return (
|
|
@@ -307,13 +307,13 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
307
307
|
<Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
|
|
308
308
|
This permanently deletes {confirmDeleteProfile.name}’s profile. This cannot be undone.
|
|
309
309
|
</Text>
|
|
310
|
-
{/* Contained neutral, not buttonColor={colors.primary}
|
|
310
|
+
{/* Contained neutral, not buttonColor={colors.primary} - colors.primary is often a
|
|
311
311
|
live gameplay/seat color in host apps, so a contained Cancel in that color would just
|
|
312
312
|
be whatever's currently painted there, with no relation to "safe to tap". */}
|
|
313
313
|
<Button mode='contained' onPress={() => setConfirmDeleteId(null)} style={styles.overlayButton} buttonColor={cancelBg} textColor={cancelFg}>
|
|
314
314
|
Cancel
|
|
315
315
|
</Button>
|
|
316
|
-
{/* colors.danger/onDanger
|
|
316
|
+
{/* colors.danger/onDanger - auto-paper's actual semantic role for this, not a seat's
|
|
317
317
|
own live color by luck of the default palette. */}
|
|
318
318
|
<Button mode='contained' onPress={handleConfirmDelete} style={styles.overlayButton} buttonColor={colors.danger} textColor={colors.onDanger}>
|
|
319
319
|
Delete
|
|
@@ -334,7 +334,7 @@ interface EditRowProps {
|
|
|
334
334
|
draftTag: string
|
|
335
335
|
onTagChange: (tag: string) => void
|
|
336
336
|
onSubmit: () => void
|
|
337
|
-
// Omitted for the 'new' row
|
|
337
|
+
// Omitted for the 'new' row - nothing saved yet to delete.
|
|
338
338
|
onDelete?: () => void
|
|
339
339
|
autoFocus?: boolean
|
|
340
340
|
fgMuted: string
|
|
@@ -356,29 +356,29 @@ function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof Inline
|
|
|
356
356
|
return <InlineColorPicker {...props} alignOverride={alignOverride} />
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker)
|
|
360
|
-
// own display via its `tag` prop below, since there's no separate visible tag field anymore
|
|
359
|
+
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) - doubling as the tag's
|
|
360
|
+
// own display via its `tag` prop below, since there's no separate visible tag field anymore - an
|
|
361
361
|
// invisible TextInput that exists purely to actually capture what's typed (RN has no way to bring
|
|
362
362
|
// up a keyboard without a real focusable input; this one is just never meant to be *seen*), the
|
|
363
|
-
// name taking up the row's remaining space, and
|
|
363
|
+
// name taking up the row's remaining space, and - only for an existing profile - a delete icon at
|
|
364
364
|
// the very end. The two effects below are what wires tapping the swatch to popping the keyboard on
|
|
365
365
|
// the hidden tag field too, so typing a tag and picking a color read as one combined action instead
|
|
366
366
|
// of two separate taps, with the swatch itself as the only place the tag ever visibly shows.
|
|
367
367
|
// Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
|
|
368
|
-
// own
|
|
369
|
-
// onSubmitEditing (a no-op
|
|
368
|
+
// own - submitting the *name* field is what actually commits the row, so its registration's own
|
|
369
|
+
// onSubmitEditing (a no-op - it's last in the chain) is overridden with onSubmit below, matching
|
|
370
370
|
// how a normal multi-field form reads: fill fields in order, the last one finishes it.
|
|
371
371
|
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }: EditRowProps) {
|
|
372
372
|
const register = useFocusChain()
|
|
373
373
|
const tag = register()
|
|
374
374
|
const name = register()
|
|
375
375
|
|
|
376
|
-
// tag.props.focus (from @rific/focus-chain's own register(), not tag.ref
|
|
376
|
+
// tag.props.focus (from @rific/focus-chain's own register(), not tag.ref - that's a callback
|
|
377
377
|
// ref, not a ref object, so it has no .current to read) is the hook's own imperative "focus this
|
|
378
378
|
// exact registered field" call. Mirrored into a ref every render, same as commitEditRef above,
|
|
379
379
|
// rather than read directly in the effect below: register() returns a brand-new props object on
|
|
380
380
|
// every render (it isn't memoized), so depending on tag.props.focus directly would rerun that
|
|
381
|
-
// effect
|
|
381
|
+
// effect - and refocus an already-focused field - on every keystroke into the tag field itself,
|
|
382
382
|
// re-triggering its own selectTextOnFocus mid-type and wiping out what's being typed.
|
|
383
383
|
const tagFocusRef = useRef(tag.props.focus)
|
|
384
384
|
useEffect(() => {
|
|
@@ -397,9 +397,9 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
397
397
|
<View style={styles.editRow}>
|
|
398
398
|
{useColorPickerAlign ? <AlignedColorPicker useAlign={useColorPickerAlign} id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} /> : <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />}
|
|
399
399
|
{/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
|
|
400
|
-
doesn't reserve space in the row's own flex layout)
|
|
400
|
+
doesn't reserve space in the row's own flex layout) - the swatch above is the only place this
|
|
401
401
|
value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
|
|
402
|
-
way: without it, this field
|
|
402
|
+
way: without it, this field - even at 1x1 and fully transparent - still sits in front of the
|
|
403
403
|
swatch for hit-testing purposes and swallows the tap meant to open the color popover, since
|
|
404
404
|
focusing this field is never done by tapping it directly anyway (see the effect above, which
|
|
405
405
|
focuses it programmatically). accessibilityLabel stands in for the placeholder a visible field
|
|
@@ -439,7 +439,7 @@ const styles = StyleSheet.create({
|
|
|
439
439
|
gap: 4,
|
|
440
440
|
paddingBottom: 8
|
|
441
441
|
},
|
|
442
|
-
// 1x1 and fully transparent
|
|
442
|
+
// 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
|
|
443
443
|
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
444
444
|
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
445
445
|
hiddenTagInput: {
|
package/src/ProfilesScreen.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { StyleSheet, View } from 'react-native'
|
|
|
6
6
|
import { ProfileEditPatch, ProfilesManager, ProfilesManagerHandle, ProfilesManagerProps } from './ProfilesManager'
|
|
7
7
|
import { Profile } from './types'
|
|
8
8
|
|
|
9
|
-
// react-native-paper's own MD3 Text variant names
|
|
9
|
+
// react-native-paper's own MD3 Text variant names - mirrored locally rather than importing its
|
|
10
10
|
// internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
|
|
11
11
|
// as ProfilesManager's own identical titleVariant prop does.
|
|
12
12
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
@@ -16,22 +16,22 @@ export interface ProfilesScreenProps {
|
|
|
16
16
|
// Same reasoning as ProfilesManager's own defaultColor: this component has no opinion on the
|
|
17
17
|
// host's palette, so it stays required rather than defaulted. Callers already differ here today
|
|
18
18
|
// (most apps pass @rific/auto-paper's defaultColors[0].value; one passes its own local
|
|
19
|
-
// DEFAULT_P1_COLOR constant instead)
|
|
19
|
+
// DEFAULT_P1_COLOR constant instead) - this prop is exactly what lets both keep working unchanged.
|
|
20
20
|
defaultColor: string
|
|
21
|
-
// Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take
|
|
21
|
+
// Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take - deliberately
|
|
22
22
|
// NOT widened to accept a host's own richer CreateProfileInput. A host whose profile shape has
|
|
23
23
|
// extra required fields (e.g. a control/key scheme) composes those in its own closure at the call
|
|
24
24
|
// site, exactly as it already does calling <ProfilesManager> today.
|
|
25
25
|
onCreate: (patch: ProfileEditPatch) => void
|
|
26
26
|
onSave: (id: string, patch: ProfileEditPatch) => void
|
|
27
|
-
// Irreversible, same as ProfilesManager's own onDelete
|
|
27
|
+
// Irreversible, same as ProfilesManager's own onDelete - fires once, after this component's
|
|
28
28
|
// internal confirmation card. If the host tracks anything else keyed by profile id (stats,
|
|
29
29
|
// achievement-unlock history), clear that on its own side inside this callback, same as every
|
|
30
30
|
// current app's `(id) => { deleteProfile(id); removeProfileStats(id) }`.
|
|
31
31
|
onDelete: (id: string) => void
|
|
32
32
|
onBack: () => void
|
|
33
33
|
// Independently optional overrides, each defaulting to the same literal-black/white-by-appearance
|
|
34
|
-
// formula every current caller renders with unchanged (none of them pass these today)
|
|
34
|
+
// formula every current caller renders with unchanged (none of them pass these today) - same
|
|
35
35
|
// convention as BaseStatsScreen's and ProfilesManager's own fg/bg-shaped overrides. fg/fgMuted/
|
|
36
36
|
// cardBg/titleVariant/colorPreview are forwarded straight through to the internal ProfilesManager.
|
|
37
37
|
fg?: string
|
|
@@ -49,13 +49,13 @@ export interface ProfilesScreenProps {
|
|
|
49
49
|
// The routed profiles-roster screen shell every app in this fleet hand-rolls around ProfilesManager
|
|
50
50
|
// today: the container View + theme-derived bg/fg + back button + commit-pending-edit-before-
|
|
51
51
|
// navigate composition. Lives here rather than in @tastic/hud since it has zero generic/non-profile
|
|
52
|
-
// UI content
|
|
53
|
-
// still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself
|
|
52
|
+
// UI content - it's one layer up from ProfilesManager, not a different kind of thing. Deliberately
|
|
53
|
+
// still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself - those two
|
|
54
54
|
// hooks' shapes are genuinely app-specific (different CreateProfileInput/GameStatsContextValue per
|
|
55
55
|
// app), so this stays props-in/callbacks-out, exactly like ProfilesManager itself.
|
|
56
56
|
export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps) {
|
|
57
57
|
const { dark } = useAutoPaperTheme()
|
|
58
|
-
// Owns the ref itself
|
|
58
|
+
// Owns the ref itself - nothing outside this component ever needs it, since this is now the one
|
|
59
59
|
// thing that both renders ProfilesManager AND owns the only navigation trigger next to it.
|
|
60
60
|
const managerRef = useRef<ProfilesManagerHandle>(null)
|
|
61
61
|
// Same literal-black/white-by-appearance formula as ProfilesManager's own fg default, resolved
|
package/src/index.ts
CHANGED
|
@@ -10,5 +10,6 @@ export { type CreateProfileInput, createProfileRecord, profilesActions, profiles
|
|
|
10
10
|
export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
|
|
11
11
|
export { createSeatColorsSlice, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileOverridePayload } from './redux/seatColorsSlice'
|
|
12
12
|
export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
|
|
13
|
-
export {
|
|
13
|
+
export { avoidSeatColorClash } from './seatColorClash'
|
|
14
|
+
export { isSharedProfileStoreAvailable, loadSharedProfiles, readSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
|
|
14
15
|
export type { Profile } from './types'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Profile } from './types'
|
|
2
2
|
|
|
3
3
|
export const MAX_PROFILE_NAME_LENGTH = 20
|
|
4
|
-
// In code points, not JS string length
|
|
4
|
+
// In code points, not JS string length - a single emoji can span more than one UTF-16 code unit
|
|
5
5
|
// (surrogate pairs, variation selectors), which .length would overcount.
|
|
6
6
|
export const MAX_TAG_LENGTH = 3
|
|
7
7
|
|
|
@@ -11,23 +11,23 @@ function isValidHexColor(value: unknown): value is string {
|
|
|
11
11
|
|
|
12
12
|
// Codepoint 0xFE0F is the variation selector that forces an emoji-style rendering of an otherwise
|
|
13
13
|
// dual-purpose glyph; 0x200D is the zero-width joiner that chains multiple pictographs into one
|
|
14
|
-
// compound emoji (a family, a flag, a skin-tone variant)
|
|
14
|
+
// compound emoji (a family, a flag, a skin-tone variant) - built from String.fromCodePoint rather
|
|
15
15
|
// than embedded directly in a regex literal so this file's own source has no invisible characters
|
|
16
16
|
// in it.
|
|
17
17
|
const VARIATION_SELECTOR = String.fromCodePoint(0xfe0f)
|
|
18
18
|
const ZERO_WIDTH_JOINER = String.fromCodePoint(0x200d)
|
|
19
|
-
// Matches one emoji as a single unit, including a ZWJ-joined compound
|
|
19
|
+
// Matches one emoji as a single unit, including a ZWJ-joined compound - good enough for telling
|
|
20
20
|
// "the user typed an emoji" from "the user typed letters" without needing full Unicode grapheme
|
|
21
21
|
// segmentation (Intl.Segmenter's own Hermes support is inconsistent enough not to lean on here).
|
|
22
22
|
const EMOJI_PATTERN = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, 'u')
|
|
23
23
|
|
|
24
|
-
// Presence check, not the full anchored EMOJI_PATTERN above
|
|
24
|
+
// Presence check, not the full anchored EMOJI_PATTERN above - clampTag below only needs to know
|
|
25
25
|
// whether a just-typed chunk contains a pictograph at all, not whether an entire value is exactly
|
|
26
26
|
// one (possibly ZWJ-joined) emoji. Same distinction @tastic/hud's InlineColorPicker draws for its
|
|
27
27
|
// own font-sizing check.
|
|
28
28
|
const EMOJI_PRESENT = /\p{Extended_Pictographic}/u
|
|
29
29
|
|
|
30
|
-
// Empty is valid too
|
|
30
|
+
// Empty is valid too - a profile can have no tag yet, same as it can go without ever entering an
|
|
31
31
|
// editor's own tag field at all; every place this renders falls back to a generic icon in that case
|
|
32
32
|
// (see ProfileChip).
|
|
33
33
|
export function isValidTag(value: unknown): value is string {
|
|
@@ -45,8 +45,8 @@ export function clampTag(previous: string, next: string): string {
|
|
|
45
45
|
const previousCodepoints = Array.from(previous)
|
|
46
46
|
const nextCodepoints = Array.from(next)
|
|
47
47
|
|
|
48
|
-
// Shrinking (backspace/delete, or a selectTextOnFocus-driven wholesale replacement
|
|
49
|
-
// ProfilesManager's own EditRow
|
|
48
|
+
// Shrinking (backspace/delete, or a selectTextOnFocus-driven wholesale replacement - see
|
|
49
|
+
// ProfilesManager's own EditRow - reporting its own replacement text as no longer than what it
|
|
50
50
|
// overwrote) always passes through untouched: there's nothing to clamp about removing content.
|
|
51
51
|
if (nextCodepoints.length <= previousCodepoints.length) return next
|
|
52
52
|
|
|
@@ -54,7 +54,7 @@ export function clampTag(previous: string, next: string): string {
|
|
|
54
54
|
while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++
|
|
55
55
|
const added = nextCodepoints.slice(sharedPrefixLength).join('')
|
|
56
56
|
|
|
57
|
-
// A tag is either a handful of plain characters or exactly one emoji, never a mix
|
|
57
|
+
// A tag is either a handful of plain characters or exactly one emoji, never a mix - typing more
|
|
58
58
|
// of either kind on top of the other one starts fresh with just what's newly typed, rather than
|
|
59
59
|
// producing a combination isValidTag would reject anyway (an emoji followed by letters, or a
|
|
60
60
|
// second emoji tacked onto the first).
|
|
@@ -63,7 +63,7 @@ export function clampTag(previous: string, next: string): string {
|
|
|
63
63
|
return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// Validates only the fields this package's own `Profile` type declares
|
|
66
|
+
// Validates only the fields this package's own `Profile` type declares - an app that extends
|
|
67
67
|
// Profile with its own fields (a control scheme, anything else) needs its own wrapper that runs
|
|
68
68
|
// this first and then checks its own additions, same as it extends the type itself. See this
|
|
69
69
|
// package's own README for the composition pattern.
|
|
@@ -10,7 +10,7 @@ export type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
|
|
|
10
10
|
|
|
11
11
|
// Same low-level shape @rific/core's createSettingsSlice gives every other slice in the fleet
|
|
12
12
|
// (namespaced type string, .type, .match). Shared internally between profilesSlice.ts and
|
|
13
|
-
// profileExtensionSlice.ts rather than each hand-rolling its own copy
|
|
13
|
+
// profileExtensionSlice.ts rather than each hand-rolling its own copy - not exported from
|
|
14
14
|
// index.ts, since it's an implementation detail of how this package's own reducers are built, not
|
|
15
15
|
// something a consuming app needs.
|
|
16
16
|
export function createActionCreator<P>(type: string): ActionCreator<P> {
|
|
@@ -19,7 +19,7 @@ export interface ProfileExtensionSlice<TExtension> {
|
|
|
19
19
|
|
|
20
20
|
// A per-profile extension is any app-specific field that doesn't belong on the shared base Profile
|
|
21
21
|
// type (a control scheme, a key-binding preference, a difficulty setting, ...) but still needs to
|
|
22
|
-
// travel WITH a specific profile id, entirely locally
|
|
22
|
+
// travel WITH a specific profile id, entirely locally - never synced to the shared App Group
|
|
23
23
|
// roster (a profile created on a sibling @tastic game has no idea this extension exists at all,
|
|
24
24
|
// same as this package's own README describes for any app-specific field). Two real @tastic games
|
|
25
25
|
// (BoxHockey's controlScheme, LightCycles' keyScheme) each hand-rolled an almost-identical
|
|
@@ -28,10 +28,10 @@ export interface ProfileExtensionSlice<TExtension> {
|
|
|
28
28
|
// need a third copy.
|
|
29
29
|
//
|
|
30
30
|
// `namespace` prefixes every action type (e.g. 'profileExtension/controlScheme/set') so an app with
|
|
31
|
-
// more than one independent per-profile extension never collides between them
|
|
31
|
+
// more than one independent per-profile extension never collides between them - same convention
|
|
32
32
|
// @rific/core's createSettingsSlice uses its own namespace argument for.
|
|
33
33
|
//
|
|
34
|
-
// Deliberately NOT auto-cleared when the base `profiles` slice removes an id
|
|
34
|
+
// Deliberately NOT auto-cleared when the base `profiles` slice removes an id - this stays a plain,
|
|
35
35
|
// self-contained reducer rather than importing profilesActions.remove's action type to listen for.
|
|
36
36
|
// An app already dispatches multiple independent actions when deleting a profile (profilesActions.
|
|
37
37
|
// remove, and typically its own local selection-slice cleanup); calling this slice's own `remove`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
2
|
|
|
3
3
|
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
4
|
-
// dependency on the package itself
|
|
4
|
+
// dependency on the package itself - this factory otherwise has no opinion on whether the
|
|
5
5
|
// consuming app uses redux-persist at all, and the string itself is a long-stable part of
|
|
6
6
|
// redux-persist's own public contract (unchanged across every major version). See the reducer's
|
|
7
7
|
// own REHYDRATE case below for why this needs handling at all.
|
|
@@ -25,30 +25,30 @@ export interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
|
25
25
|
reducer: (state: ProfileSelectionState<TSeat> | undefined, action: { type: string; payload?: unknown }) => ProfileSelectionState<TSeat>
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// Which profile each seat last picked
|
|
28
|
+
// Which profile each seat last picked - always local, never part of the shared App Group roster
|
|
29
29
|
// (this package's own profiles slice, see profilesSlice.ts): who's selected on THIS device is
|
|
30
30
|
// naturally a per-app, per-device thing, not something a sibling @tastic game should see or
|
|
31
31
|
// influence. Five @tastic games (AirHockey, BoxHockey, Pong, LightCycles, Snake) each hand-rolled
|
|
32
|
-
// an identical Record<Seat, string | null> reducer before this factory existed
|
|
32
|
+
// an identical Record<Seat, string | null> reducer before this factory existed - same duplication
|
|
33
33
|
// createProfileExtensionSlice generalized away for per-profile extensions, this is that shape for
|
|
34
34
|
// per-seat selection instead.
|
|
35
35
|
//
|
|
36
36
|
// `namespace` prefixes every action type (e.g. 'profileSelection/loadout/select') so an app with
|
|
37
|
-
// more than one independent seat-selection concern never collides between them
|
|
37
|
+
// more than one independent seat-selection concern never collides between them - same convention
|
|
38
38
|
// createProfileExtensionSlice's own namespace argument follows.
|
|
39
39
|
//
|
|
40
40
|
// Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
|
|
41
|
-
// exist at runtime), so callers pass their own `defaultState`
|
|
41
|
+
// exist at runtime), so callers pass their own `defaultState` - the same literal object (e.g.
|
|
42
42
|
// `{ 1: null, 2: null }`) each app already declares as its own `defaultProfileSelectionState`
|
|
43
43
|
// constant today. `clearProfile` walks that state's own keys rather than assuming any particular
|
|
44
44
|
// seat count, so it reproduces the 5 apps' current two-seat behavior without hardcoding "1"/"2".
|
|
45
45
|
//
|
|
46
|
-
// `mountKey` defaults to 'profileSelection'
|
|
46
|
+
// `mountKey` defaults to 'profileSelection' - the literal key every current consumer (AirHockey,
|
|
47
47
|
// BoxHockey, Pong, LightCycles, Snake) already mounts this reducer under in its own
|
|
48
48
|
// combineReducers({...}) call (e.g. `combineReducers({ ..., profileSelection })`). It has to be
|
|
49
49
|
// passed explicitly rather than inferred, since redux-persist's REHYDRATE payload is keyed by
|
|
50
50
|
// whatever name the app's own root reducer happens to use, which this factory has no way to see
|
|
51
|
-
// from inside its own reducer function
|
|
51
|
+
// from inside its own reducer function - only worth overriding if some future consumer ever mounts
|
|
52
52
|
// this under a different key. Note `namespace` above is a different thing: it's the app's own name
|
|
53
53
|
// (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key.
|
|
54
54
|
//
|
|
@@ -56,6 +56,15 @@ export interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
|
56
56
|
// after any profilesActions.setAll (initial reconcile, onRemoteChange), since a sibling app can
|
|
57
57
|
// delete a profile without this app's own deleteProfile ever running. It returns the same state
|
|
58
58
|
// object when nothing changes, so dispatching it on every foreground resume costs no re-render.
|
|
59
|
+
//
|
|
60
|
+
// One profile is never on two seats. `select` picking a profile another seat already has swaps
|
|
61
|
+
// the two: that seat takes this seat's previous pick (a profile, or null for guest), the same rule
|
|
62
|
+
// as @tastic/input's assignSeatScheme. A picker's takenProfileId only hides the other seat's
|
|
63
|
+
// profile while both seats are on screen, so without the swap a 1 Player pick of seat 2's saved
|
|
64
|
+
// profile left both seats on it, and every later 2 Player round recorded a win and a loss for that
|
|
65
|
+
// one profile. A host showing per-seat state tied to the selection (a profile's color override)
|
|
66
|
+
// should reset it for both seats when a pick swaps. REHYDRATE also drops any duplicate saved before
|
|
67
|
+
// this rule existed, keeping the profile on the first seat.
|
|
59
68
|
export function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey = 'profileSelection'): ProfileSelectionSlice<TSeat> {
|
|
60
69
|
const select = createActionCreator<SelectProfilePayload<TSeat>>(`profileSelection/${namespace}/select`)
|
|
61
70
|
const clearProfile = createActionCreator<string>(`profileSelection/${namespace}/clearProfile`)
|
|
@@ -64,16 +73,35 @@ export function createProfileSelectionSlice<TSeat extends string | number>(names
|
|
|
64
73
|
function reducer(state: ProfileSelectionState<TSeat> = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState<TSeat> {
|
|
65
74
|
// redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
|
|
66
75
|
// entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
|
|
67
|
-
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge)
|
|
76
|
+
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) -
|
|
68
77
|
// so a device with a blob persisted before a new seat existed would otherwise rehydrate with
|
|
69
78
|
// that seat's key missing entirely, and every consumer indexes into it (`state[seat]`) with no
|
|
70
79
|
// fallback. Spreading `defaultState` first, then the incoming persisted value, backfills exactly
|
|
71
|
-
// that gap
|
|
80
|
+
// that gap - the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
|
|
72
81
|
if (action.type === REHYDRATE) {
|
|
73
82
|
const persisted = (action.payload as Record<string, Partial<ProfileSelectionState<TSeat>>> | undefined)?.[mountKey]
|
|
74
|
-
|
|
83
|
+
const merged = { ...defaultState, ...state, ...persisted }
|
|
84
|
+
const seen = new Set<string>()
|
|
85
|
+
for (const seat of Object.keys(merged) as TSeat[]) {
|
|
86
|
+
const profileId = merged[seat]
|
|
87
|
+
if (profileId === null) continue
|
|
88
|
+
if (seen.has(profileId)) merged[seat] = null
|
|
89
|
+
else seen.add(profileId)
|
|
90
|
+
}
|
|
91
|
+
return merged
|
|
92
|
+
}
|
|
93
|
+
if (select.match(action)) {
|
|
94
|
+
const { profileId, seat } = action.payload
|
|
95
|
+
const previous = state[seat] ?? null
|
|
96
|
+
const next = { ...state, [seat]: profileId } as ProfileSelectionState<TSeat>
|
|
97
|
+
if (profileId !== null) {
|
|
98
|
+
// Object.keys yields strings, so a numeric seat compares as a string.
|
|
99
|
+
for (const other of Object.keys(state) as TSeat[]) {
|
|
100
|
+
if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return next
|
|
75
104
|
}
|
|
76
|
-
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId } as ProfileSelectionState<TSeat>
|
|
77
105
|
if (clearProfile.match(action)) {
|
|
78
106
|
const next = { ...state }
|
|
79
107
|
for (const seat of Object.keys(state) as TSeat[]) {
|
|
@@ -1,23 +1,27 @@
|
|
|
1
|
-
import { isSharedProfileStoreAvailable,
|
|
1
|
+
import { isSharedProfileStoreAvailable, readSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
|
|
2
2
|
import { Profile } from '../types'
|
|
3
3
|
|
|
4
4
|
// Resolves the roster an app should start with, given whatever it already had saved in its own
|
|
5
|
-
// local fallback storage (however that app stores it
|
|
6
|
-
// only the shared App Group store via
|
|
5
|
+
// local fallback storage (however that app stores it - this never touches AsyncStorage itself,
|
|
6
|
+
// only the shared App Group store via readSharedProfiles/saveSharedProfiles). Every @tastic game
|
|
7
7
|
// hand-rolled an identical version of this exact decision inline in its own useProfiles.tsx; this
|
|
8
8
|
// is that logic, extracted verbatim.
|
|
9
9
|
//
|
|
10
10
|
// - Shared store unavailable (Android/web, or an iOS build that hasn't run `expo prebuild` since
|
|
11
11
|
// the entitlement was added): `localFallback` IS the roster, verbatim.
|
|
12
|
+
// - Shared store available, but its roster couldn't be read (readSharedProfiles' null):
|
|
13
|
+
// `localFallback`, and nothing is written. Seeding here would overwrite every other app's
|
|
14
|
+
// profiles with this app's local snapshot.
|
|
12
15
|
// - Shared store available, and genuinely empty: either a first launch, or the first time THIS
|
|
13
|
-
// app has synced since the App Group entitlement was added
|
|
16
|
+
// app has synced since the App Group entitlement was added - seed it from `localFallback` (so
|
|
14
17
|
// an upgrading user's own profiles don't just vanish), and mirror that seed to the shared store
|
|
15
18
|
// (fire-and-forget; a failed seed write just means the next successful write catches up).
|
|
16
19
|
// - Shared store available and non-empty: shared always wins over a stale local snapshot, never
|
|
17
|
-
// merged with it
|
|
20
|
+
// merged with it - avoids resurrecting a profile that was deleted from another app.
|
|
18
21
|
export async function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]> {
|
|
19
22
|
if (!isSharedProfileStoreAvailable) return localFallback
|
|
20
|
-
const shared = await
|
|
23
|
+
const shared = await readSharedProfiles(groupId)
|
|
24
|
+
if (shared === null) return localFallback
|
|
21
25
|
if (shared.length === 0 && localFallback.length > 0) {
|
|
22
26
|
saveSharedProfiles(groupId, localFallback).catch(() => {})
|
|
23
27
|
return localFallback
|