@tastic/profile 0.3.8 → 0.7.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 +162 -15
- package/dist/index.d.mts +124 -3
- package/dist/index.d.ts +124 -3
- package/dist/index.js +183 -2
- package/dist/index.mjs +173 -1
- package/package.json +8 -7
- package/src/ProfilesManager.tsx +4 -1
- package/src/ProfilesScreen.tsx +83 -0
- package/src/index.ts +8 -1
- package/src/redux/createActionCreator.ts +21 -0
- package/src/redux/profileExtensionSlice.ts +55 -0
- package/src/redux/profileSelectionSlice.ts +81 -0
- package/src/redux/profilesSlice.ts +47 -0
- package/src/redux/resolveInitialProfiles.ts +26 -0
- package/src/redux/seatColorsSlice.ts +96 -0
- package/src/redux/useSharedProfilesSync.ts +74 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
|
+
|
|
3
|
+
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
4
|
+
// dependency on the package itself — this factory otherwise has no opinion on whether the
|
|
5
|
+
// consuming app uses redux-persist at all, and the string itself is a long-stable part of
|
|
6
|
+
// redux-persist's own public contract (unchanged across every major version). See the reducer's
|
|
7
|
+
// own REHYDRATE case below for why this needs handling at all.
|
|
8
|
+
const REHYDRATE = 'persist/REHYDRATE'
|
|
9
|
+
|
|
10
|
+
export interface SelectProfilePayload<TSeat> {
|
|
11
|
+
profileId: string | null
|
|
12
|
+
seat: TSeat
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProfileSelectionActions<TSeat> {
|
|
16
|
+
clearProfile: ActionCreator<string>
|
|
17
|
+
select: ActionCreator<SelectProfilePayload<TSeat>>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>
|
|
21
|
+
|
|
22
|
+
export interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
23
|
+
actions: ProfileSelectionActions<TSeat>
|
|
24
|
+
reducer: (state: ProfileSelectionState<TSeat> | undefined, action: { type: string; payload?: unknown }) => ProfileSelectionState<TSeat>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Which profile each seat last picked — always local, never part of the shared App Group roster
|
|
28
|
+
// (this package's own profiles slice, see profilesSlice.ts): who's selected on THIS device is
|
|
29
|
+
// naturally a per-app, per-device thing, not something a sibling @tastic game should see or
|
|
30
|
+
// influence. Five @tastic games (AirHockey, BoxHockey, Pong, LightCycles, Snake) each hand-rolled
|
|
31
|
+
// an identical Record<Seat, string | null> reducer before this factory existed — same duplication
|
|
32
|
+
// createProfileExtensionSlice generalized away for per-profile extensions, this is that shape for
|
|
33
|
+
// per-seat selection instead.
|
|
34
|
+
//
|
|
35
|
+
// `namespace` prefixes every action type (e.g. 'profileSelection/loadout/select') so an app with
|
|
36
|
+
// more than one independent seat-selection concern never collides between them — same convention
|
|
37
|
+
// createProfileExtensionSlice's own namespace argument follows.
|
|
38
|
+
//
|
|
39
|
+
// Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
|
|
40
|
+
// exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
|
|
41
|
+
// `{ 1: null, 2: null }`) each app already declares as its own `defaultProfileSelectionState`
|
|
42
|
+
// constant today. `clearProfile` walks that state's own keys rather than assuming any particular
|
|
43
|
+
// seat count, so it reproduces the 5 apps' current two-seat behavior without hardcoding "1"/"2".
|
|
44
|
+
//
|
|
45
|
+
// `mountKey` defaults to 'profileSelection' — the literal key every current consumer (AirHockey,
|
|
46
|
+
// BoxHockey, Pong, LightCycles, Snake) already mounts this reducer under in its own
|
|
47
|
+
// combineReducers({...}) call (e.g. `combineReducers({ ..., profileSelection })`). It has to be
|
|
48
|
+
// passed explicitly rather than inferred, since redux-persist's REHYDRATE payload is keyed by
|
|
49
|
+
// whatever name the app's own root reducer happens to use, which this factory has no way to see
|
|
50
|
+
// from inside its own reducer function — only worth overriding if some future consumer ever mounts
|
|
51
|
+
// this under a different key. Note `namespace` above is a different thing: it's the app's own name
|
|
52
|
+
// (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key.
|
|
53
|
+
export function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey = 'profileSelection'): ProfileSelectionSlice<TSeat> {
|
|
54
|
+
const select = createActionCreator<SelectProfilePayload<TSeat>>(`profileSelection/${namespace}/select`)
|
|
55
|
+
const clearProfile = createActionCreator<string>(`profileSelection/${namespace}/clearProfile`)
|
|
56
|
+
|
|
57
|
+
function reducer(state: ProfileSelectionState<TSeat> = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState<TSeat> {
|
|
58
|
+
// redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
|
|
59
|
+
// entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
|
|
60
|
+
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
|
|
61
|
+
// so a device with a blob persisted before a new seat existed would otherwise rehydrate with
|
|
62
|
+
// that seat's key missing entirely, and every consumer indexes into it (`state[seat]`) with no
|
|
63
|
+
// fallback. Spreading `defaultState` first, then the incoming persisted value, backfills exactly
|
|
64
|
+
// that gap — the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
|
|
65
|
+
if (action.type === REHYDRATE) {
|
|
66
|
+
const persisted = (action.payload as Record<string, Partial<ProfileSelectionState<TSeat>>> | undefined)?.[mountKey]
|
|
67
|
+
return { ...defaultState, ...state, ...persisted }
|
|
68
|
+
}
|
|
69
|
+
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId } as ProfileSelectionState<TSeat>
|
|
70
|
+
if (clearProfile.match(action)) {
|
|
71
|
+
const next = { ...state }
|
|
72
|
+
for (const seat of Object.keys(state) as TSeat[]) {
|
|
73
|
+
if (next[seat] === action.payload) next[seat] = null
|
|
74
|
+
}
|
|
75
|
+
return next
|
|
76
|
+
}
|
|
77
|
+
return state
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { actions: { clearProfile, select }, reducer }
|
|
81
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Profile } from '../types'
|
|
2
|
+
import { createActionCreator } from './createActionCreator'
|
|
3
|
+
|
|
4
|
+
export interface UpdateProfilePayload {
|
|
5
|
+
id: string
|
|
6
|
+
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// setAll: full-replace, for hydrating from resolveInitialProfiles or a remote refresh
|
|
10
|
+
// (useSharedProfilesSync's onRemoteChange). add/update/remove: the actual roster CRUD every
|
|
11
|
+
// @tastic game already hand-rolled an identical version of in its own useProfiles.tsx.
|
|
12
|
+
const setAll = createActionCreator<Profile[]>('profiles/setAll')
|
|
13
|
+
const add = createActionCreator<Profile>('profiles/add')
|
|
14
|
+
const update = createActionCreator<UpdateProfilePayload>('profiles/update')
|
|
15
|
+
const remove = createActionCreator<string>('profiles/remove')
|
|
16
|
+
|
|
17
|
+
export const profilesActions = { add, remove, setAll, update }
|
|
18
|
+
|
|
19
|
+
export function profilesReducer(state: Profile[] = [], action: { type: string }): Profile[] {
|
|
20
|
+
if (setAll.match(action)) return action.payload
|
|
21
|
+
if (add.match(action)) return [...state, action.payload]
|
|
22
|
+
if (update.match(action)) {
|
|
23
|
+
const { id, patch } = action.payload
|
|
24
|
+
return state.map((p) => (p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p))
|
|
25
|
+
}
|
|
26
|
+
if (remove.match(action)) return state.filter((p) => p.id !== action.payload)
|
|
27
|
+
return state
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function generateProfileId(): string {
|
|
31
|
+
return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CreateProfileInput {
|
|
35
|
+
name: string
|
|
36
|
+
color: string
|
|
37
|
+
tag: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Builds a complete Profile (id + timestamps) ready to dispatch via profilesActions.add. Kept as
|
|
41
|
+
// its own plain function rather than folded into the `add` action creator, so `add` itself stays a
|
|
42
|
+
// simple "append this exact record" primitive (useful for rehydration/tests too, where the record
|
|
43
|
+
// already exists and shouldn't get a second, different id/timestamp minted for it).
|
|
44
|
+
export function createProfileRecord(input: CreateProfileInput): Profile {
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now }
|
|
47
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
|
|
2
|
+
import { Profile } from '../types'
|
|
3
|
+
|
|
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 — this never touches AsyncStorage itself,
|
|
6
|
+
// only the shared App Group store via loadSharedProfiles/saveSharedProfiles). Every @tastic game
|
|
7
|
+
// hand-rolled an identical version of this exact decision inline in its own useProfiles.tsx; this
|
|
8
|
+
// is that logic, extracted verbatim.
|
|
9
|
+
//
|
|
10
|
+
// - Shared store unavailable (Android/web, or an iOS build that hasn't run `expo prebuild` since
|
|
11
|
+
// the entitlement was added): `localFallback` IS the roster, verbatim.
|
|
12
|
+
// - 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 — seed it from `localFallback` (so
|
|
14
|
+
// an upgrading user's own profiles don't just vanish), and mirror that seed to the shared store
|
|
15
|
+
// (fire-and-forget; a failed seed write just means the next successful write catches up).
|
|
16
|
+
// - Shared store available and non-empty: shared always wins over a stale local snapshot, never
|
|
17
|
+
// merged with it — avoids resurrecting a profile that was deleted from another app.
|
|
18
|
+
export async function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]> {
|
|
19
|
+
if (!isSharedProfileStoreAvailable) return localFallback
|
|
20
|
+
const shared = await loadSharedProfiles(groupId)
|
|
21
|
+
if (shared.length === 0 && localFallback.length > 0) {
|
|
22
|
+
saveSharedProfiles(groupId, localFallback).catch(() => {})
|
|
23
|
+
return localFallback
|
|
24
|
+
}
|
|
25
|
+
return shared
|
|
26
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
|
+
|
|
3
|
+
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
4
|
+
// dependency on the package itself — this factory otherwise has no opinion on whether the
|
|
5
|
+
// consuming app uses redux-persist at all, and the string itself is a long-stable part of
|
|
6
|
+
// redux-persist's own public contract (unchanged across every major version). See the reducer's
|
|
7
|
+
// own REHYDRATE case below for why this needs handling at all.
|
|
8
|
+
const REHYDRATE = 'persist/REHYDRATE'
|
|
9
|
+
|
|
10
|
+
export interface SetLastGuestColorPayload<TSeat> {
|
|
11
|
+
color: string
|
|
12
|
+
seat: TSeat
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SetProfileOverridePayload<TSeat> {
|
|
16
|
+
color: string | null
|
|
17
|
+
seat: TSeat
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SeatColorsActions<TSeat> {
|
|
21
|
+
setLastCpuColor: ActionCreator<string>
|
|
22
|
+
setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>
|
|
23
|
+
setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SeatColorsState<TSeat extends string | number> = {
|
|
27
|
+
lastCpuColor: string
|
|
28
|
+
lastGuestColor: Record<TSeat, string>
|
|
29
|
+
profileOverride: Record<TSeat, string | null>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SeatColorsSlice<TSeat extends string | number> {
|
|
33
|
+
actions: SeatColorsActions<TSeat>
|
|
34
|
+
reducer: (state: SeatColorsState<TSeat> | undefined, action: { type: string; payload?: unknown }) => SeatColorsState<TSeat>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Which color a seat should show right now, remembered independently for three sources that must
|
|
38
|
+
// never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and a
|
|
39
|
+
// manual recolor/clash-swap override landing on a seat that currently has a profile selected (null
|
|
40
|
+
// meaning "no override, track that profile's own saved color live"). Four @tastic games (AirHockey,
|
|
41
|
+
// BoxHockey, LightCycles, Pong) each hand-rolled an identical dedicated seatColorsSlice.ts — same
|
|
42
|
+
// three fields, same createSlice-based reducer, differing only in their own seat type's name
|
|
43
|
+
// (Player) and default-color constants — before this factory existed; this is that shape,
|
|
44
|
+
// generalized over the seat type exactly like createProfileSelectionSlice generalized per-seat
|
|
45
|
+
// profile selection.
|
|
46
|
+
//
|
|
47
|
+
// Snake has the same three fields too, but folded directly into its own catch-all gameSlice.ts
|
|
48
|
+
// alongside a dozen unrelated persisted settings (wrapEdges, cpuDifficulty, speedTier, ...) rather
|
|
49
|
+
// than in a standalone slice of their own. There's no separate `seatColors` sub-state there for this
|
|
50
|
+
// factory's reducer to own without restructuring gameSlice's top-level shape to nest these three
|
|
51
|
+
// fields under their own key first — a real state-shape migration (and a redux-persist rehydration
|
|
52
|
+
// concern, see gameSlice's own REHYDRATE extraReducer), not a drop-in swap. This factory
|
|
53
|
+
// intentionally targets the standalone-slice shape the other four apps already have; Snake staying
|
|
54
|
+
// hand-rolled inside gameSlice is a deliberate scope call for this pass, not an oversight.
|
|
55
|
+
//
|
|
56
|
+
// `namespace` prefixes every action type (e.g. 'seatColors/loadout/setLastGuestColor') so an app
|
|
57
|
+
// with more than one independent seat-colors concern never collides between them — same convention
|
|
58
|
+
// createProfileSelectionSlice's and createProfileExtensionSlice's own namespace arguments follow.
|
|
59
|
+
//
|
|
60
|
+
// Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
|
|
61
|
+
// exist at runtime), so callers pass their own `defaultState` — same reasoning, and the same
|
|
62
|
+
// {1: ..., 2: ...}-shaped literal each app already declares as its own defaultSeatColorsState
|
|
63
|
+
// constant today, as createProfileSelectionSlice's own defaultState parameter.
|
|
64
|
+
//
|
|
65
|
+
// `mountKey` defaults to 'seatColors' — the literal key every current consumer (AirHockey,
|
|
66
|
+
// BoxHockey, Pong) already mounts this reducer under in its own combineReducers({...}) call (e.g.
|
|
67
|
+
// `combineReducers({ ..., seatColors })`). It has to be passed explicitly rather than inferred,
|
|
68
|
+
// since redux-persist's REHYDRATE payload is keyed by whatever name the app's own root reducer
|
|
69
|
+
// happens to use, which this factory has no way to see from inside its own reducer function — only
|
|
70
|
+
// worth overriding if some future consumer ever mounts this under a different key.
|
|
71
|
+
export function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey = 'seatColors'): SeatColorsSlice<TSeat> {
|
|
72
|
+
const setLastCpuColor = createActionCreator<string>(`seatColors/${namespace}/setLastCpuColor`)
|
|
73
|
+
const setLastGuestColor = createActionCreator<SetLastGuestColorPayload<TSeat>>(`seatColors/${namespace}/setLastGuestColor`)
|
|
74
|
+
const setProfileOverride = createActionCreator<SetProfileOverridePayload<TSeat>>(`seatColors/${namespace}/setProfileOverride`)
|
|
75
|
+
|
|
76
|
+
function reducer(state: SeatColorsState<TSeat> = defaultState, action: { type: string; payload?: unknown }): SeatColorsState<TSeat> {
|
|
77
|
+
// redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
|
|
78
|
+
// entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
|
|
79
|
+
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
|
|
80
|
+
// so a device with a blob persisted before `profileOverride` existed would otherwise rehydrate
|
|
81
|
+
// with `state.profileOverride === undefined`, and every consumer indexes into it (`profileOverride[seat]`)
|
|
82
|
+
// with no fallback, throwing. Spreading `defaultState` first, then the incoming persisted
|
|
83
|
+
// value, backfills exactly that gap — the same pattern Snake's own hand-rolled `gameSlice.ts`
|
|
84
|
+
// already uses for its equivalent (non-factory) fields via its own REHYDRATE extraReducer.
|
|
85
|
+
if (action.type === REHYDRATE) {
|
|
86
|
+
const persisted = (action.payload as Record<string, Partial<SeatColorsState<TSeat>>> | undefined)?.[mountKey]
|
|
87
|
+
return { ...defaultState, ...state, ...persisted }
|
|
88
|
+
}
|
|
89
|
+
if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload }
|
|
90
|
+
if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } as Record<TSeat, string> }
|
|
91
|
+
if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } as Record<TSeat, string | null> }
|
|
92
|
+
return state
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer }
|
|
96
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import { AppState } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
|
|
5
|
+
import { Profile } from '../types'
|
|
6
|
+
|
|
7
|
+
export interface UseSharedProfilesSyncOptions {
|
|
8
|
+
groupId: string
|
|
9
|
+
// Called with a freshly-read shared roster whenever the app returns to the foreground — the
|
|
10
|
+
// shared store has no cross-process change notification, so this is what catches "edited a
|
|
11
|
+
// profile in a different @tastic app, then switched back to this one." Skipped while a
|
|
12
|
+
// syncToShared write is still in flight (see pendingWriteRef below), and never called at all
|
|
13
|
+
// when the shared store isn't available.
|
|
14
|
+
onRemoteChange: (profiles: Profile[]) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseSharedProfilesSyncResult {
|
|
18
|
+
// Call right after applying a roster change locally (dispatching profilesActions.setAll/add/
|
|
19
|
+
// update/remove) to mirror it to the shared store. No-ops when the shared store isn't available
|
|
20
|
+
// — the caller is responsible for its own local persistence in that case, same as before this
|
|
21
|
+
// hook existed. Fire-and-forget: never throws, never awaited by this hook itself.
|
|
22
|
+
syncToShared: (next: Profile[]) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Bundles the two things every @tastic game needs around the shared App Group roster once
|
|
26
|
+
// `profiles` lives in its own Redux store: mirroring local writes out to the shared store, and
|
|
27
|
+
// picking up remote writes a sibling app made while this one was backgrounded. Deliberately
|
|
28
|
+
// separate from resolveInitialProfiles — that one's for the one-time initial load, this one's for
|
|
29
|
+
// the app's whole lifetime after that.
|
|
30
|
+
export function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult {
|
|
31
|
+
// True for the duration of an in-flight syncToShared write — the foreground-refresh effect below
|
|
32
|
+
// skips a refresh while this is true, rather than risk reading back a pre-write snapshot and
|
|
33
|
+
// handing the caller an onRemoteChange that reverts the write still landing. A ref, not state:
|
|
34
|
+
// read from inside the AppState listener's closure, which is only created once per subscription
|
|
35
|
+
// and would otherwise see whatever this was at mount time, forever.
|
|
36
|
+
const pendingWriteRef = useRef(false)
|
|
37
|
+
// Same reasoning, for onRemoteChange itself — an app typically passes an inline callback that
|
|
38
|
+
// closes over its own latest dispatch/state, so the listener must always call the current one,
|
|
39
|
+
// not whichever was in scope when the effect below first subscribed.
|
|
40
|
+
const onRemoteChangeRef = useRef(onRemoteChange)
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
onRemoteChangeRef.current = onRemoteChange
|
|
43
|
+
}, [onRemoteChange])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isSharedProfileStoreAvailable) return
|
|
47
|
+
let cancelled = false
|
|
48
|
+
const sub = AppState.addEventListener('change', (next) => {
|
|
49
|
+
if (next !== 'active' || pendingWriteRef.current) return
|
|
50
|
+
loadSharedProfiles(groupId).then((shared) => {
|
|
51
|
+
if (!cancelled) onRemoteChangeRef.current(shared)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true
|
|
56
|
+
sub.remove()
|
|
57
|
+
}
|
|
58
|
+
}, [groupId])
|
|
59
|
+
|
|
60
|
+
const syncToShared = useCallback(
|
|
61
|
+
(next: Profile[]) => {
|
|
62
|
+
if (!isSharedProfileStoreAvailable) return
|
|
63
|
+
pendingWriteRef.current = true
|
|
64
|
+
saveSharedProfiles(groupId, next)
|
|
65
|
+
.catch(() => {})
|
|
66
|
+
.finally(() => {
|
|
67
|
+
pendingWriteRef.current = false
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
[groupId]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return { syncToShared }
|
|
74
|
+
}
|