@tastic/profile 0.16.1 → 0.17.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 +27 -15
- package/dist/index.d.mts +7 -6
- package/dist/index.d.ts +7 -6
- package/dist/index.js +78 -25
- package/dist/index.mjs +78 -25
- package/package.json +9 -8
- package/src/IdentityChip.tsx +4 -3
- package/src/OptionPicker.tsx +2 -0
- package/src/PickerMenu.tsx +53 -18
- package/src/ProfileColorContext.tsx +18 -9
- package/src/ProfilePicker.tsx +2 -1
- package/src/ProfilesManager.tsx +77 -51
- package/src/SwatchLayer.tsx +12 -6
- package/src/index.ts +1 -1
- package/src/sharedProfileStore.ts +35 -5
package/src/ProfilesManager.tsx
CHANGED
|
@@ -2,15 +2,15 @@ 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 type { PopoverHost } from '@tastic/hud/chrome'
|
|
5
|
+
import type { InlineColorPickerSwatchRenderer, PopoverHost, ViewInstance } from '@tastic/hud/chrome'
|
|
6
6
|
import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from '@tastic/hud/chrome'
|
|
7
|
-
import { ComponentProps, ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import { ComponentProps, ReactNode, Ref, RefObject, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import { KeyboardAvoidingView, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
9
9
|
import { Icon, Text, TextInput } from 'react-native-paper'
|
|
10
10
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
11
11
|
|
|
12
12
|
import { ProfileChip } from './ProfileChip'
|
|
13
|
-
import { useProfileColorPreview } from './ProfileColorContext'
|
|
13
|
+
import { type ProfileSwatchRenderer, useProfileColorPreview, useProfileSwatchRenderer } from './ProfileColorContext'
|
|
14
14
|
import { type ProfilePickerAlignResult } from './ProfilePicker'
|
|
15
15
|
import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
|
|
16
16
|
import { Profile } from './types'
|
|
@@ -88,12 +88,21 @@ export interface ProfilesManagerProps {
|
|
|
88
88
|
// measured). It is a hook, not a plain function: give it a STABLE identity for the life of the mount (a
|
|
89
89
|
// module-level function) - switching it at runtime remounts the swatch. Omit for the ordinary case;
|
|
90
90
|
// behaviour is then identical to before. Needs @tastic/hud >= 0.12.0 to have any effect (older hud has no
|
|
91
|
-
// `alignOverride` and ignores it).
|
|
92
|
-
|
|
91
|
+
// `alignOverride` and ignores it). Its fourth argument, `column`, is the roster's scroll view: the popover
|
|
92
|
+
// opens inside it and is clipped at its edges, so where it's narrower than the frame (a column capped in a
|
|
93
|
+
// wide window) the placement should keep the popover inside it. A hook that measures it never has to be
|
|
94
|
+
// told the column's width. A hook that ignores it behaves as before.
|
|
95
|
+
useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
|
|
96
99
|
|
|
100
|
+
// A focus-chain ref as a Paper TextInput takes it. Paper types its input's handle as
|
|
101
|
+
// Pick<TextInput, 'focus' | ...>, which React Native 0.88's strict types read as `focus: unknown`
|
|
102
|
+
// (TextInput is a function there, not a class), so focus-chain's ref, which wants `focus(): void`,
|
|
103
|
+
// no longer fits the prop. The handle does have focus() at run time.
|
|
104
|
+
const textInputRef = (ref: (el: { focus: () => void } | null) => void) => ref as (el: unknown) => void
|
|
105
|
+
|
|
97
106
|
// First letter of the name, uppercased - the tag's own default for as long as it hasn't been typed
|
|
98
107
|
// into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
|
|
99
108
|
// nothing into the tag field yourself would leave it.
|
|
@@ -120,6 +129,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
120
129
|
const { dark } = useAutoPaperTheme()
|
|
121
130
|
const insets = useSafeAreaInsets()
|
|
122
131
|
const { error: showErrorToast } = useToast()
|
|
132
|
+
// The roster's scroll view, which clips the color popover: the color picker keeps its popover
|
|
133
|
+
// inside it (InlineColorPicker's `bounds`), and a host's useColorPickerAlign is handed it.
|
|
134
|
+
const columnRef = useRef<ViewInstance>(null)
|
|
135
|
+
// ProfileColorProvider's swatch renderer, for the color picker's trigger and swatches.
|
|
136
|
+
const renderSwatch = useProfileSwatchRenderer()
|
|
123
137
|
// Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
|
|
124
138
|
// so every EditRow instance can safely share this one host instead of each needing its own.
|
|
125
139
|
const host = usePopoverHost()
|
|
@@ -247,46 +261,50 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
247
261
|
fade out at an edge while there is more past it, the same cue as the Settings sheet and the
|
|
248
262
|
Achievements screen (Jay, 2026-10-16: every scrolling screen alike). */}
|
|
249
263
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardAvoiding}>
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
264
|
+
{/* A plain View around the scroll view, the same size, for the column the color popover
|
|
265
|
+
is kept inside (see columnRef): the scroll view takes no ref. */}
|
|
266
|
+
<View ref={columnRef} collapsable={false} style={styles.column}>
|
|
267
|
+
<FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
|
|
268
|
+
{rows.map((row) => {
|
|
269
|
+
if (row.kind === 'new') {
|
|
270
|
+
if (editingId !== 'new') {
|
|
271
|
+
return (
|
|
272
|
+
<TouchableRipple key='new' onPress={() => startEdit('new')} style={styles.row}>
|
|
273
|
+
<View style={styles.rowInner}>
|
|
274
|
+
<View style={[styles.rowChip, styles.newChip, { borderColor: fg }]}>
|
|
275
|
+
<Icon source='plus' size={20} color={fg} />
|
|
276
|
+
</View>
|
|
277
|
+
<Text variant='bodyLarge' style={{ color: fg }}>
|
|
278
|
+
New Profile
|
|
279
|
+
</Text>
|
|
259
280
|
</View>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
</TouchableRipple>
|
|
265
|
-
)
|
|
281
|
+
</TouchableRipple>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} column={columnRef} renderSwatch={renderSwatch} />
|
|
266
285
|
}
|
|
267
|
-
return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} />
|
|
268
|
-
}
|
|
269
286
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
287
|
+
const { profile } = row
|
|
288
|
+
if (editingId === profile.id) {
|
|
289
|
+
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} column={columnRef} renderSwatch={renderSwatch} />
|
|
290
|
+
}
|
|
274
291
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
292
|
+
// No delete affordance at rest - tap the row to start editing, which is the one place
|
|
293
|
+
// delete shows (see EditRow): a deliberate extra step before a destructive action is
|
|
294
|
+
// even reachable, not just before it's confirmed.
|
|
295
|
+
return (
|
|
296
|
+
<TouchableRipple key={profile.id} onPress={() => startEdit(profile)} style={styles.row}>
|
|
297
|
+
<View style={styles.rowInner}>
|
|
298
|
+
<ProfileChip profile={profile} size={CHIP_SIZE} />
|
|
299
|
+
<Text variant='bodyLarge' style={[styles.rowName, { color: fg }]} numberOfLines={1}>
|
|
300
|
+
{profile.name}
|
|
301
|
+
</Text>
|
|
302
|
+
</View>
|
|
303
|
+
</TouchableRipple>
|
|
304
|
+
)
|
|
305
|
+
})}
|
|
306
|
+
</FadingScrollView>
|
|
307
|
+
</View>
|
|
290
308
|
</KeyboardAvoidingView>
|
|
291
309
|
|
|
292
310
|
{/* hud's ConfirmDialog, the one confirm card every app in the fleet uses, so this reads like the
|
|
@@ -314,6 +332,8 @@ interface EditRowProps {
|
|
|
314
332
|
dark: boolean
|
|
315
333
|
colorPreview?: (hex: string) => string
|
|
316
334
|
useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign']
|
|
335
|
+
column: RefObject<ViewInstance | null>
|
|
336
|
+
renderSwatch?: ProfileSwatchRenderer
|
|
317
337
|
}
|
|
318
338
|
|
|
319
339
|
// InlineColorPicker with its placement supplied by a host hook (see ProfilesManagerProps.useColorPickerAlign).
|
|
@@ -321,10 +341,10 @@ interface EditRowProps {
|
|
|
321
341
|
// unconditionally here, and the plain path below stays byte-identical to what it was. Sized exactly as
|
|
322
342
|
// InlineColorPicker sizes itself (the default swatch list at the raw window width), so the host's placement and
|
|
323
343
|
// the popover it positions agree on its footprint.
|
|
324
|
-
function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof InlineColorPicker> & { useAlign: NonNullable<ProfilesManagerProps['useColorPickerAlign']
|
|
344
|
+
function AlignedColorPicker({ useAlign, column, ...props }: ComponentProps<typeof InlineColorPicker> & { useAlign: NonNullable<ProfilesManagerProps['useColorPickerAlign']>; column: EditRowProps['column'] }) {
|
|
325
345
|
const { width: windowWidth } = useWindowDimensions()
|
|
326
346
|
const { width, height } = getInlineColorPickerContentSize(defaultColors.length, windowWidth)
|
|
327
|
-
const alignOverride = useAlign(props.host.openId === props.id, width, height)
|
|
347
|
+
const alignOverride = useAlign(props.host.openId === props.id, width, height, column)
|
|
328
348
|
return <InlineColorPicker {...props} alignOverride={alignOverride} />
|
|
329
349
|
}
|
|
330
350
|
|
|
@@ -340,7 +360,11 @@ function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof Inline
|
|
|
340
360
|
// own - submitting the *name* field is what actually commits the row, so its registration's own
|
|
341
361
|
// onSubmitEditing (a no-op - it's last in the chain) is overridden with onSubmit below, matching
|
|
342
362
|
// how a normal multi-field form reads: fill fields in order, the last one finishes it.
|
|
343
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }: EditRowProps) {
|
|
363
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign, column, renderSwatch }: EditRowProps) {
|
|
364
|
+
// ProfileColorProvider's renderer in the color picker: its trigger is the profile's color with its
|
|
365
|
+
// tag on it, an identity like a chip, and its grid is where a color is chosen, like a picker's color
|
|
366
|
+
// page (Hangman draws its triad pies on the grid only).
|
|
367
|
+
const pickerSwatch = useMemo<InlineColorPickerSwatchRenderer | undefined>(() => (renderSwatch ? (color, size, place) => renderSwatch(color, size, place === 'trigger' ? 'chip' : 'colorPage') : undefined), [renderSwatch])
|
|
344
368
|
const register = useFocusChain()
|
|
345
369
|
const tag = register()
|
|
346
370
|
const name = register()
|
|
@@ -367,7 +391,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
367
391
|
|
|
368
392
|
return (
|
|
369
393
|
<View style={styles.editRow}>
|
|
370
|
-
{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} />}
|
|
394
|
+
{useColorPickerAlign ? <AlignedColorPicker useAlign={useColorPickerAlign} column={column} id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} renderSwatch={pickerSwatch} /> : <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} renderSwatch={pickerSwatch} bounds={column} />}
|
|
371
395
|
{/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
|
|
372
396
|
doesn't reserve space in the row's own flex layout) - the swatch above is the only place this
|
|
373
397
|
value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
|
|
@@ -379,12 +403,10 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
379
403
|
find it as "Tag" the same way they find the Name field by its placeholder. selectTextOnFocus
|
|
380
404
|
so re-tapping the swatch to revise an already-set tag replaces it wholesale rather than
|
|
381
405
|
inserting mid-string. */}
|
|
382
|
-
{
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
<TextInput ref={
|
|
386
|
-
{/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
|
|
387
|
-
<TextInput ref={name.ref} {...name.props} onSubmitEditing={onSubmit} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} />
|
|
406
|
+
{}
|
|
407
|
+
<TextInput ref={textInputRef(tag.ref)} {...tag.props} value={draftTag} onChangeText={onTagChange} accessibilityLabel='Tag' autoCapitalize='characters' selectTextOnFocus returnKeyType='next' style={styles.hiddenTagInput} pointerEvents='none' />
|
|
408
|
+
{}
|
|
409
|
+
<TextInput ref={textInputRef(name.ref)} {...name.props} onSubmitEditing={onSubmit} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} />
|
|
388
410
|
{onDelete && (
|
|
389
411
|
<TouchableRipple onPress={onDelete} style={styles.iconButton}>
|
|
390
412
|
<Icon source='trash-can-outline' size={18} color={fgMuted} />
|
|
@@ -395,6 +417,10 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
395
417
|
}
|
|
396
418
|
|
|
397
419
|
const styles = StyleSheet.create({
|
|
420
|
+
// The roster's column, around the scroll view (see columnRef).
|
|
421
|
+
column: {
|
|
422
|
+
flex: 1
|
|
423
|
+
},
|
|
398
424
|
content: {
|
|
399
425
|
gap: 4,
|
|
400
426
|
paddingHorizontal: 20
|
package/src/SwatchLayer.tsx
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
1
2
|
import { StyleSheet, View } from 'react-native'
|
|
2
3
|
|
|
3
|
-
import { type ProfileSwatchRenderer } from './ProfileColorContext'
|
|
4
|
+
import { type ProfileSwatchPlace, type ProfileSwatchRenderer } from './ProfileColorContext'
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
|
-
render: ProfileSwatchRenderer
|
|
7
|
-
// The drawn color, handed to `render` as is.
|
|
8
|
-
color: string
|
|
9
7
|
// The circle's diameter: the swatch's own size, less any ring drawn inside it.
|
|
10
8
|
size: number
|
|
9
|
+
// What the renderer drew (see drawSwatch).
|
|
10
|
+
children: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// What a ProfileColorProvider `renderSwatch` draws for one swatch, or null for the flat fill: there's
|
|
14
|
+
// no renderer, or it returned null or undefined for this place.
|
|
15
|
+
export function drawSwatch(render: ProfileSwatchRenderer | undefined, color: string, size: number, place: ProfileSwatchPlace): ReactNode {
|
|
16
|
+
return render ? (render(color, size, place) ?? null) : null
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
// A ProfileColorProvider `renderSwatch` drawing in place of a swatch's flat fill: absolutely placed at
|
|
14
20
|
// the swatch's top left (inside any border), behind the mark drawn after it, and clipped to the circle
|
|
15
21
|
// so a square drawing still reads as the round swatch. Not exported from index.ts.
|
|
16
|
-
export function SwatchLayer({
|
|
17
|
-
return <View style={[styles.layer, { borderRadius: size / 2, height: size, width: size }]}>{
|
|
22
|
+
export function SwatchLayer({ size, children }: Props) {
|
|
23
|
+
return <View style={[styles.layer, { borderRadius: size / 2, height: size, width: size }]}>{children}</View>
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
const styles = StyleSheet.create({
|
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, type ProfileSwatchRenderer, useProfileColorPreview, useProfileDisplayColor, useProfileSwatchRenderer } from './ProfileColorContext'
|
|
3
|
+
export { ProfileColorProvider, type ProfileSwatchPlace, 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'
|
|
@@ -122,15 +122,45 @@ export async function loadSharedProfiles(groupId: string): Promise<Profile[]> {
|
|
|
122
122
|
return (await readSharedProfiles(groupId)) ?? []
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
const hasId = (entry: unknown): entry is { id: string } => typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string' && (entry as { id: string }).id !== ''
|
|
126
|
+
|
|
127
|
+
// The stored roster's entries this version can't read (they fail isValidProfile: a newer app's
|
|
128
|
+
// profile under a rule loosened since, say) and that aren't in `profiles`, so saveSharedProfiles can
|
|
129
|
+
// write them back as they are. This app never had them in its roster, so a save that left them out
|
|
130
|
+
// would delete them for every app in the group. Only entries with an id are kept (anything else is
|
|
131
|
+
// unusable to every app). [] when the stored roster can't be read at all. The web store is read
|
|
132
|
+
// directly, past its failed-write latch: that latch is there so a stale roster can't replace a
|
|
133
|
+
// local one, and nothing read here replaces anything.
|
|
134
|
+
async function unreadableEntries(groupId: string, profiles: Profile[]): Promise<unknown[]> {
|
|
135
|
+
let json: string | null | undefined
|
|
136
|
+
try {
|
|
137
|
+
json = web ? web.storage.getItem(webKey(groupId, PROFILES_KEY)) : await native?.getSharedJSON(groupId, PROFILES_KEY)
|
|
138
|
+
} catch {
|
|
139
|
+
return []
|
|
140
|
+
}
|
|
141
|
+
if (!json) return []
|
|
142
|
+
try {
|
|
143
|
+
const parsed: unknown = JSON.parse(json)
|
|
144
|
+
if (!Array.isArray(parsed)) return []
|
|
145
|
+
const saved = new Set(profiles.map((profile) => profile.id))
|
|
146
|
+
return parsed.filter((entry) => !isValidProfile(entry) && hasId(entry) && !saved.has(entry.id))
|
|
147
|
+
} catch {
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
125
152
|
// Overwrites the entire shared roster - same whole-blob-write discipline a host app's own
|
|
126
|
-
// AsyncStorage-backed profile store already uses, not an incremental per-profile update
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
153
|
+
// AsyncStorage-backed profile store already uses, not an incremental per-profile update - except
|
|
154
|
+
// for the stored entries this version can't read, which it keeps (see unreadableEntries), after
|
|
155
|
+
// `profiles`. A write failure (store unavailable, or some other native error) is swallowed rather
|
|
156
|
+
// than thrown: the caller's own local state is already the source of truth for what just changed,
|
|
157
|
+
// and this is a best-effort mirror of it, not the other way around. A failed read before it writes
|
|
158
|
+
// `profiles` alone, as every save did before entries were kept.
|
|
130
159
|
export async function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void> {
|
|
131
160
|
if (!native) return
|
|
132
161
|
try {
|
|
133
|
-
await
|
|
162
|
+
const kept = await unreadableEntries(groupId, profiles)
|
|
163
|
+
await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify([...profiles, ...kept]))
|
|
134
164
|
} catch {
|
|
135
165
|
// Best-effort - see doc comment above.
|
|
136
166
|
}
|