@tastic/profile 0.3.8 → 0.4.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 +44 -5
- package/dist/index.d.mts +60 -1
- package/dist/index.d.ts +60 -1
- package/dist/index.js +112 -2
- package/dist/index.mjs +105 -1
- package/package.json +5 -4
- package/src/index.ts +4 -0
- package/src/redux/createActionCreator.ts +21 -0
- package/src/redux/profileExtensionSlice.ts +55 -0
- package/src/redux/profilesSlice.ts +47 -0
- package/src/redux/resolveInitialProfiles.ts +26 -0
- package/src/redux/useSharedProfilesSync.ts +74 -0
package/README.md
CHANGED
|
@@ -67,7 +67,8 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
67
67
|
TypeScript type.
|
|
68
68
|
|
|
69
69
|
```ts
|
|
70
|
-
//
|
|
70
|
+
// Conceptual sketch of the pattern — see "Redux helpers for the shared roster" below for a
|
|
71
|
+
// ready-made version of it.
|
|
71
72
|
const GROUP_ID = 'group.com.yourteam.yourgames'
|
|
72
73
|
const base = isSharedProfileStoreAvailable ? await loadSharedProfiles(GROUP_ID) : localBaseFallback
|
|
73
74
|
const profiles = base.map((p) => ({ ...p, controlScheme: localExtensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME }))
|
|
@@ -87,9 +88,42 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
87
88
|
{ "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
### Redux helpers for the shared roster
|
|
92
|
+
|
|
93
|
+
The cross-app-roster sketch above is a pattern every `@tastic` game was hand-rolling on its own
|
|
94
|
+
(a roster reducer, a per-profile extension table, the initial-load decision, a foreground-resync
|
|
95
|
+
listener). These four pieces are that logic extracted into reusable, Redux-shaped building blocks —
|
|
96
|
+
plain TypeScript, no dependency on `redux`/`react-redux` themselves, so a host app plugs the
|
|
97
|
+
reducer(s) into whatever store it already has:
|
|
98
|
+
|
|
99
|
+
- **`profilesActions` / `profilesReducer` / `createProfileRecord`** — the base roster as a reducer:
|
|
100
|
+
`add`/`update`/`remove`/`setAll` actions, plus `createProfileRecord(input)` to mint a complete
|
|
101
|
+
`Profile` (id + timestamps) ready to dispatch via `profilesActions.add`.
|
|
102
|
+
- **`createProfileExtensionSlice<TExtension>(namespace)`** — a factory for a host app's own
|
|
103
|
+
per-profile extension fields (a control scheme, a key scheme, ...): returns a
|
|
104
|
+
`{ actions: { set, remove }, reducer }` pair over a plain `Record<profileId, TExtension>`,
|
|
105
|
+
namespaced so more than one extension slice in the same app never collides. Not auto-cleared when
|
|
106
|
+
`profilesReducer` removes a profile — an app already dispatches its own cleanup action(s) on
|
|
107
|
+
delete, so this stays a self-contained reducer rather than new cross-slice wiring.
|
|
108
|
+
- **`resolveInitialProfiles(groupId, localFallback)`** — the one-time initial-load decision: shared
|
|
109
|
+
store unavailable → `localFallback` verbatim; shared store empty → seed it from `localFallback`;
|
|
110
|
+
otherwise the shared roster always wins over a possibly-stale local snapshot.
|
|
111
|
+
- **`useSharedProfilesSync({ groupId, onRemoteChange })`** — keeps a Redux-backed roster in sync for
|
|
112
|
+
the rest of the app's lifetime: call the returned `syncToShared(next)` right after a local roster
|
|
113
|
+
change to mirror it out, and `onRemoteChange` fires with a freshly-loaded roster whenever the app
|
|
114
|
+
returns to the foreground (skipped while a `syncToShared` write is still in flight, so a remote
|
|
115
|
+
refresh can't clobber a write that hasn't landed yet).
|
|
116
|
+
|
|
117
|
+
## Install
|
|
91
118
|
|
|
92
|
-
|
|
119
|
+
```bash
|
|
120
|
+
npm install @tastic/profile
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
|
|
124
|
+
`npm run release:patch`/`:minor`/`:major`); no local linking needed for normal use.
|
|
125
|
+
|
|
126
|
+
To develop against a local change before it's published, use `yalc` instead:
|
|
93
127
|
|
|
94
128
|
```bash
|
|
95
129
|
cd react-native-game-profile
|
|
@@ -109,5 +143,10 @@ linked consumer at once.
|
|
|
109
143
|
`react`, `react-native`, `react-native-paper`, `react-native-safe-area-context`, `@rific/auto-paper`
|
|
110
144
|
(`getContrastColor`, `useAutoPaperTheme`), `@rific/feedback-press` (`TouchableRipple`),
|
|
111
145
|
`@rific/focus-chain` (`useFocusChain`), `@rific/toaster` (`useToast`), `@tastic/hud`
|
|
112
|
-
(`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`)
|
|
113
|
-
are bundled, so use whatever versions your app
|
|
146
|
+
(`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`), `@tastic/core`
|
|
147
|
+
(a peer of `@tastic/hud` itself) — none of these are bundled, so use whatever versions your app
|
|
148
|
+
already has.
|
|
149
|
+
|
|
150
|
+
`expo-modules-core` is an *optional* peer — only resolved by `loadSharedProfiles`/`saveSharedProfiles`
|
|
151
|
+
for the native App Group bridge described above; safe to omit entirely on an Android/web-only
|
|
152
|
+
consumer.
|
package/dist/index.d.mts
CHANGED
|
@@ -65,8 +65,67 @@ declare const MAX_TAG_LENGTH = 3;
|
|
|
65
65
|
declare function isValidTag(value: unknown): value is string;
|
|
66
66
|
declare function isValidProfile(value: unknown): value is Profile;
|
|
67
67
|
|
|
68
|
+
interface ReduxAction<P> {
|
|
69
|
+
payload: P;
|
|
70
|
+
type: string;
|
|
71
|
+
}
|
|
72
|
+
type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
|
|
73
|
+
type: string;
|
|
74
|
+
match: (action: {
|
|
75
|
+
type: string;
|
|
76
|
+
}) => action is ReduxAction<P>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface SetProfileExtensionPayload<TExtension> {
|
|
80
|
+
extension: TExtension;
|
|
81
|
+
id: string;
|
|
82
|
+
}
|
|
83
|
+
interface ProfileExtensionActions<TExtension> {
|
|
84
|
+
remove: ActionCreator<string>;
|
|
85
|
+
set: ActionCreator<SetProfileExtensionPayload<TExtension>>;
|
|
86
|
+
}
|
|
87
|
+
type ProfileExtensionState<TExtension> = Record<string, TExtension>;
|
|
88
|
+
interface ProfileExtensionSlice<TExtension> {
|
|
89
|
+
actions: ProfileExtensionActions<TExtension>;
|
|
90
|
+
reducer: (state: ProfileExtensionState<TExtension> | undefined, action: {
|
|
91
|
+
type: string;
|
|
92
|
+
}) => ProfileExtensionState<TExtension>;
|
|
93
|
+
}
|
|
94
|
+
declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
|
|
95
|
+
|
|
96
|
+
interface UpdateProfilePayload {
|
|
97
|
+
id: string;
|
|
98
|
+
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
|
|
99
|
+
}
|
|
100
|
+
declare const profilesActions: {
|
|
101
|
+
add: ActionCreator<Profile>;
|
|
102
|
+
remove: ActionCreator<string>;
|
|
103
|
+
setAll: ActionCreator<Profile[]>;
|
|
104
|
+
update: ActionCreator<UpdateProfilePayload>;
|
|
105
|
+
};
|
|
106
|
+
declare function profilesReducer(state: Profile[] | undefined, action: {
|
|
107
|
+
type: string;
|
|
108
|
+
}): Profile[];
|
|
109
|
+
interface CreateProfileInput {
|
|
110
|
+
name: string;
|
|
111
|
+
color: string;
|
|
112
|
+
tag: string;
|
|
113
|
+
}
|
|
114
|
+
declare function createProfileRecord(input: CreateProfileInput): Profile;
|
|
115
|
+
|
|
116
|
+
declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
|
|
117
|
+
|
|
118
|
+
interface UseSharedProfilesSyncOptions {
|
|
119
|
+
groupId: string;
|
|
120
|
+
onRemoteChange: (profiles: Profile[]) => void;
|
|
121
|
+
}
|
|
122
|
+
interface UseSharedProfilesSyncResult {
|
|
123
|
+
syncToShared: (next: Profile[]) => void;
|
|
124
|
+
}
|
|
125
|
+
declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
|
|
126
|
+
|
|
68
127
|
declare const isSharedProfileStoreAvailable: boolean;
|
|
69
128
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
70
129
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
71
130
|
|
|
72
|
-
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
|
131
|
+
export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, type SetProfileExtensionPayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
|
package/dist/index.d.ts
CHANGED
|
@@ -65,8 +65,67 @@ declare const MAX_TAG_LENGTH = 3;
|
|
|
65
65
|
declare function isValidTag(value: unknown): value is string;
|
|
66
66
|
declare function isValidProfile(value: unknown): value is Profile;
|
|
67
67
|
|
|
68
|
+
interface ReduxAction<P> {
|
|
69
|
+
payload: P;
|
|
70
|
+
type: string;
|
|
71
|
+
}
|
|
72
|
+
type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
|
|
73
|
+
type: string;
|
|
74
|
+
match: (action: {
|
|
75
|
+
type: string;
|
|
76
|
+
}) => action is ReduxAction<P>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface SetProfileExtensionPayload<TExtension> {
|
|
80
|
+
extension: TExtension;
|
|
81
|
+
id: string;
|
|
82
|
+
}
|
|
83
|
+
interface ProfileExtensionActions<TExtension> {
|
|
84
|
+
remove: ActionCreator<string>;
|
|
85
|
+
set: ActionCreator<SetProfileExtensionPayload<TExtension>>;
|
|
86
|
+
}
|
|
87
|
+
type ProfileExtensionState<TExtension> = Record<string, TExtension>;
|
|
88
|
+
interface ProfileExtensionSlice<TExtension> {
|
|
89
|
+
actions: ProfileExtensionActions<TExtension>;
|
|
90
|
+
reducer: (state: ProfileExtensionState<TExtension> | undefined, action: {
|
|
91
|
+
type: string;
|
|
92
|
+
}) => ProfileExtensionState<TExtension>;
|
|
93
|
+
}
|
|
94
|
+
declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
|
|
95
|
+
|
|
96
|
+
interface UpdateProfilePayload {
|
|
97
|
+
id: string;
|
|
98
|
+
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
|
|
99
|
+
}
|
|
100
|
+
declare const profilesActions: {
|
|
101
|
+
add: ActionCreator<Profile>;
|
|
102
|
+
remove: ActionCreator<string>;
|
|
103
|
+
setAll: ActionCreator<Profile[]>;
|
|
104
|
+
update: ActionCreator<UpdateProfilePayload>;
|
|
105
|
+
};
|
|
106
|
+
declare function profilesReducer(state: Profile[] | undefined, action: {
|
|
107
|
+
type: string;
|
|
108
|
+
}): Profile[];
|
|
109
|
+
interface CreateProfileInput {
|
|
110
|
+
name: string;
|
|
111
|
+
color: string;
|
|
112
|
+
tag: string;
|
|
113
|
+
}
|
|
114
|
+
declare function createProfileRecord(input: CreateProfileInput): Profile;
|
|
115
|
+
|
|
116
|
+
declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
|
|
117
|
+
|
|
118
|
+
interface UseSharedProfilesSyncOptions {
|
|
119
|
+
groupId: string;
|
|
120
|
+
onRemoteChange: (profiles: Profile[]) => void;
|
|
121
|
+
}
|
|
122
|
+
interface UseSharedProfilesSyncResult {
|
|
123
|
+
syncToShared: (next: Profile[]) => void;
|
|
124
|
+
}
|
|
125
|
+
declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
|
|
126
|
+
|
|
68
127
|
declare const isSharedProfileStoreAvailable: boolean;
|
|
69
128
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
70
129
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
71
130
|
|
|
72
|
-
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
|
131
|
+
export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, type SetProfileExtensionPayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
|
package/dist/index.js
CHANGED
|
@@ -25,11 +25,17 @@ __export(index_exports, {
|
|
|
25
25
|
ProfileChip: () => ProfileChip,
|
|
26
26
|
ProfilePicker: () => ProfilePicker,
|
|
27
27
|
ProfilesManager: () => ProfilesManager,
|
|
28
|
+
createProfileExtensionSlice: () => createProfileExtensionSlice,
|
|
29
|
+
createProfileRecord: () => createProfileRecord,
|
|
28
30
|
isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
|
|
29
31
|
isValidProfile: () => isValidProfile,
|
|
30
32
|
isValidTag: () => isValidTag,
|
|
31
33
|
loadSharedProfiles: () => loadSharedProfiles,
|
|
32
|
-
|
|
34
|
+
profilesActions: () => profilesActions,
|
|
35
|
+
profilesReducer: () => profilesReducer,
|
|
36
|
+
resolveInitialProfiles: () => resolveInitialProfiles,
|
|
37
|
+
saveSharedProfiles: () => saveSharedProfiles,
|
|
38
|
+
useSharedProfilesSync: () => useSharedProfilesSync
|
|
33
39
|
});
|
|
34
40
|
module.exports = __toCommonJS(index_exports);
|
|
35
41
|
|
|
@@ -508,6 +514,55 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
508
514
|
}
|
|
509
515
|
});
|
|
510
516
|
|
|
517
|
+
// src/redux/createActionCreator.ts
|
|
518
|
+
function createActionCreator(type) {
|
|
519
|
+
const creator = ((payload) => ({ payload, type }));
|
|
520
|
+
creator.type = type;
|
|
521
|
+
creator.match = (action) => action.type === type;
|
|
522
|
+
return creator;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/redux/profileExtensionSlice.ts
|
|
526
|
+
function createProfileExtensionSlice(namespace) {
|
|
527
|
+
const set = createActionCreator(`profileExtension/${namespace}/set`);
|
|
528
|
+
const remove2 = createActionCreator(`profileExtension/${namespace}/remove`);
|
|
529
|
+
function reducer(state = {}, action) {
|
|
530
|
+
if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension };
|
|
531
|
+
if (remove2.match(action)) {
|
|
532
|
+
if (!(action.payload in state)) return state;
|
|
533
|
+
const next = { ...state };
|
|
534
|
+
delete next[action.payload];
|
|
535
|
+
return next;
|
|
536
|
+
}
|
|
537
|
+
return state;
|
|
538
|
+
}
|
|
539
|
+
return { actions: { remove: remove2, set }, reducer };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/redux/profilesSlice.ts
|
|
543
|
+
var setAll = createActionCreator("profiles/setAll");
|
|
544
|
+
var add = createActionCreator("profiles/add");
|
|
545
|
+
var update = createActionCreator("profiles/update");
|
|
546
|
+
var remove = createActionCreator("profiles/remove");
|
|
547
|
+
var profilesActions = { add, remove, setAll, update };
|
|
548
|
+
function profilesReducer(state = [], action) {
|
|
549
|
+
if (setAll.match(action)) return action.payload;
|
|
550
|
+
if (add.match(action)) return [...state, action.payload];
|
|
551
|
+
if (update.match(action)) {
|
|
552
|
+
const { id, patch } = action.payload;
|
|
553
|
+
return state.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p);
|
|
554
|
+
}
|
|
555
|
+
if (remove.match(action)) return state.filter((p) => p.id !== action.payload);
|
|
556
|
+
return state;
|
|
557
|
+
}
|
|
558
|
+
function generateProfileId() {
|
|
559
|
+
return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
560
|
+
}
|
|
561
|
+
function createProfileRecord(input) {
|
|
562
|
+
const now = Date.now();
|
|
563
|
+
return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now };
|
|
564
|
+
}
|
|
565
|
+
|
|
511
566
|
// src/sharedProfileStore.ts
|
|
512
567
|
var native = null;
|
|
513
568
|
try {
|
|
@@ -541,6 +596,55 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
541
596
|
} catch {
|
|
542
597
|
}
|
|
543
598
|
}
|
|
599
|
+
|
|
600
|
+
// src/redux/resolveInitialProfiles.ts
|
|
601
|
+
async function resolveInitialProfiles(groupId, localFallback) {
|
|
602
|
+
if (!isSharedProfileStoreAvailable) return localFallback;
|
|
603
|
+
const shared = await loadSharedProfiles(groupId);
|
|
604
|
+
if (shared.length === 0 && localFallback.length > 0) {
|
|
605
|
+
saveSharedProfiles(groupId, localFallback).catch(() => {
|
|
606
|
+
});
|
|
607
|
+
return localFallback;
|
|
608
|
+
}
|
|
609
|
+
return shared;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// src/redux/useSharedProfilesSync.ts
|
|
613
|
+
var import_react3 = require("react");
|
|
614
|
+
var import_react_native4 = require("react-native");
|
|
615
|
+
function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
616
|
+
const pendingWriteRef = (0, import_react3.useRef)(false);
|
|
617
|
+
const onRemoteChangeRef = (0, import_react3.useRef)(onRemoteChange);
|
|
618
|
+
(0, import_react3.useEffect)(() => {
|
|
619
|
+
onRemoteChangeRef.current = onRemoteChange;
|
|
620
|
+
}, [onRemoteChange]);
|
|
621
|
+
(0, import_react3.useEffect)(() => {
|
|
622
|
+
if (!isSharedProfileStoreAvailable) return;
|
|
623
|
+
let cancelled = false;
|
|
624
|
+
const sub = import_react_native4.AppState.addEventListener("change", (next) => {
|
|
625
|
+
if (next !== "active" || pendingWriteRef.current) return;
|
|
626
|
+
loadSharedProfiles(groupId).then((shared) => {
|
|
627
|
+
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
return () => {
|
|
631
|
+
cancelled = true;
|
|
632
|
+
sub.remove();
|
|
633
|
+
};
|
|
634
|
+
}, [groupId]);
|
|
635
|
+
const syncToShared = (0, import_react3.useCallback)(
|
|
636
|
+
(next) => {
|
|
637
|
+
if (!isSharedProfileStoreAvailable) return;
|
|
638
|
+
pendingWriteRef.current = true;
|
|
639
|
+
saveSharedProfiles(groupId, next).catch(() => {
|
|
640
|
+
}).finally(() => {
|
|
641
|
+
pendingWriteRef.current = false;
|
|
642
|
+
});
|
|
643
|
+
},
|
|
644
|
+
[groupId]
|
|
645
|
+
);
|
|
646
|
+
return { syncToShared };
|
|
647
|
+
}
|
|
544
648
|
// Annotate the CommonJS export names for ESM import in node:
|
|
545
649
|
0 && (module.exports = {
|
|
546
650
|
MAX_PROFILE_NAME_LENGTH,
|
|
@@ -548,9 +652,15 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
548
652
|
ProfileChip,
|
|
549
653
|
ProfilePicker,
|
|
550
654
|
ProfilesManager,
|
|
655
|
+
createProfileExtensionSlice,
|
|
656
|
+
createProfileRecord,
|
|
551
657
|
isSharedProfileStoreAvailable,
|
|
552
658
|
isValidProfile,
|
|
553
659
|
isValidTag,
|
|
554
660
|
loadSharedProfiles,
|
|
555
|
-
|
|
661
|
+
profilesActions,
|
|
662
|
+
profilesReducer,
|
|
663
|
+
resolveInitialProfiles,
|
|
664
|
+
saveSharedProfiles,
|
|
665
|
+
useSharedProfilesSync
|
|
556
666
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -480,6 +480,55 @@ var styles3 = StyleSheet3.create({
|
|
|
480
480
|
}
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
// src/redux/createActionCreator.ts
|
|
484
|
+
function createActionCreator(type) {
|
|
485
|
+
const creator = ((payload) => ({ payload, type }));
|
|
486
|
+
creator.type = type;
|
|
487
|
+
creator.match = (action) => action.type === type;
|
|
488
|
+
return creator;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/redux/profileExtensionSlice.ts
|
|
492
|
+
function createProfileExtensionSlice(namespace) {
|
|
493
|
+
const set = createActionCreator(`profileExtension/${namespace}/set`);
|
|
494
|
+
const remove2 = createActionCreator(`profileExtension/${namespace}/remove`);
|
|
495
|
+
function reducer(state = {}, action) {
|
|
496
|
+
if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension };
|
|
497
|
+
if (remove2.match(action)) {
|
|
498
|
+
if (!(action.payload in state)) return state;
|
|
499
|
+
const next = { ...state };
|
|
500
|
+
delete next[action.payload];
|
|
501
|
+
return next;
|
|
502
|
+
}
|
|
503
|
+
return state;
|
|
504
|
+
}
|
|
505
|
+
return { actions: { remove: remove2, set }, reducer };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/redux/profilesSlice.ts
|
|
509
|
+
var setAll = createActionCreator("profiles/setAll");
|
|
510
|
+
var add = createActionCreator("profiles/add");
|
|
511
|
+
var update = createActionCreator("profiles/update");
|
|
512
|
+
var remove = createActionCreator("profiles/remove");
|
|
513
|
+
var profilesActions = { add, remove, setAll, update };
|
|
514
|
+
function profilesReducer(state = [], action) {
|
|
515
|
+
if (setAll.match(action)) return action.payload;
|
|
516
|
+
if (add.match(action)) return [...state, action.payload];
|
|
517
|
+
if (update.match(action)) {
|
|
518
|
+
const { id, patch } = action.payload;
|
|
519
|
+
return state.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p);
|
|
520
|
+
}
|
|
521
|
+
if (remove.match(action)) return state.filter((p) => p.id !== action.payload);
|
|
522
|
+
return state;
|
|
523
|
+
}
|
|
524
|
+
function generateProfileId() {
|
|
525
|
+
return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
526
|
+
}
|
|
527
|
+
function createProfileRecord(input) {
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now };
|
|
530
|
+
}
|
|
531
|
+
|
|
483
532
|
// src/sharedProfileStore.ts
|
|
484
533
|
var native = null;
|
|
485
534
|
try {
|
|
@@ -513,15 +562,70 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
513
562
|
} catch {
|
|
514
563
|
}
|
|
515
564
|
}
|
|
565
|
+
|
|
566
|
+
// src/redux/resolveInitialProfiles.ts
|
|
567
|
+
async function resolveInitialProfiles(groupId, localFallback) {
|
|
568
|
+
if (!isSharedProfileStoreAvailable) return localFallback;
|
|
569
|
+
const shared = await loadSharedProfiles(groupId);
|
|
570
|
+
if (shared.length === 0 && localFallback.length > 0) {
|
|
571
|
+
saveSharedProfiles(groupId, localFallback).catch(() => {
|
|
572
|
+
});
|
|
573
|
+
return localFallback;
|
|
574
|
+
}
|
|
575
|
+
return shared;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/redux/useSharedProfilesSync.ts
|
|
579
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
580
|
+
import { AppState } from "react-native";
|
|
581
|
+
function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
582
|
+
const pendingWriteRef = useRef2(false);
|
|
583
|
+
const onRemoteChangeRef = useRef2(onRemoteChange);
|
|
584
|
+
useEffect2(() => {
|
|
585
|
+
onRemoteChangeRef.current = onRemoteChange;
|
|
586
|
+
}, [onRemoteChange]);
|
|
587
|
+
useEffect2(() => {
|
|
588
|
+
if (!isSharedProfileStoreAvailable) return;
|
|
589
|
+
let cancelled = false;
|
|
590
|
+
const sub = AppState.addEventListener("change", (next) => {
|
|
591
|
+
if (next !== "active" || pendingWriteRef.current) return;
|
|
592
|
+
loadSharedProfiles(groupId).then((shared) => {
|
|
593
|
+
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
return () => {
|
|
597
|
+
cancelled = true;
|
|
598
|
+
sub.remove();
|
|
599
|
+
};
|
|
600
|
+
}, [groupId]);
|
|
601
|
+
const syncToShared = useCallback2(
|
|
602
|
+
(next) => {
|
|
603
|
+
if (!isSharedProfileStoreAvailable) return;
|
|
604
|
+
pendingWriteRef.current = true;
|
|
605
|
+
saveSharedProfiles(groupId, next).catch(() => {
|
|
606
|
+
}).finally(() => {
|
|
607
|
+
pendingWriteRef.current = false;
|
|
608
|
+
});
|
|
609
|
+
},
|
|
610
|
+
[groupId]
|
|
611
|
+
);
|
|
612
|
+
return { syncToShared };
|
|
613
|
+
}
|
|
516
614
|
export {
|
|
517
615
|
MAX_PROFILE_NAME_LENGTH,
|
|
518
616
|
MAX_TAG_LENGTH,
|
|
519
617
|
ProfileChip,
|
|
520
618
|
ProfilePicker,
|
|
521
619
|
ProfilesManager,
|
|
620
|
+
createProfileExtensionSlice,
|
|
621
|
+
createProfileRecord,
|
|
522
622
|
isSharedProfileStoreAvailable,
|
|
523
623
|
isValidProfile,
|
|
524
624
|
isValidTag,
|
|
525
625
|
loadSharedProfiles,
|
|
526
|
-
|
|
626
|
+
profilesActions,
|
|
627
|
+
profilesReducer,
|
|
628
|
+
resolveInitialProfiles,
|
|
629
|
+
saveSharedProfiles,
|
|
630
|
+
useSharedProfilesSync
|
|
527
631
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -65,12 +65,13 @@
|
|
|
65
65
|
"@infinitetoken/eslint-config": "0.2.0",
|
|
66
66
|
"@infinitetoken/jest-config": "^0.2.4",
|
|
67
67
|
"@infinitetoken/tsconfig": "^0.4.1",
|
|
68
|
-
"@rific/auto-paper": "^0.
|
|
69
|
-
"@rific/
|
|
68
|
+
"@rific/auto-paper": "^0.11.0",
|
|
69
|
+
"@rific/core": "^0.1.1",
|
|
70
|
+
"@rific/feedback-press": "^0.11.1",
|
|
70
71
|
"@rific/focus-chain": "^0.4.7",
|
|
71
72
|
"@rific/toaster": "^0.6.3",
|
|
72
73
|
"@tastic/core": "^0.2.0",
|
|
73
|
-
"@tastic/hud": "^0.
|
|
74
|
+
"@tastic/hud": "^0.8.5",
|
|
74
75
|
"@testing-library/dom": "^10.4.1",
|
|
75
76
|
"@testing-library/react": "^16.3.2",
|
|
76
77
|
"@types/jest": "^30.0.0",
|
package/src/index.ts
CHANGED
|
@@ -2,5 +2,9 @@ export { ProfileChip } from './ProfileChip'
|
|
|
2
2
|
export { ProfilePicker } from './ProfilePicker'
|
|
3
3
|
export { ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
|
|
4
4
|
export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
|
|
5
|
+
export { createProfileExtensionSlice, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, type SetProfileExtensionPayload } from './redux/profileExtensionSlice'
|
|
6
|
+
export { type CreateProfileInput, createProfileRecord, profilesActions, profilesReducer, type UpdateProfilePayload } from './redux/profilesSlice'
|
|
7
|
+
export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
|
|
8
|
+
export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
|
|
5
9
|
export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
|
|
6
10
|
export type { Profile } from './types'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ReduxAction<P> {
|
|
2
|
+
payload: P
|
|
3
|
+
type: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
|
|
7
|
+
type: string
|
|
8
|
+
match: (action: { type: string }) => action is ReduxAction<P>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Same low-level shape @rific/core's createSettingsSlice gives every other slice in the fleet
|
|
12
|
+
// (namespaced type string, .type, .match). Shared internally between profilesSlice.ts and
|
|
13
|
+
// profileExtensionSlice.ts rather than each hand-rolling its own copy — not exported from
|
|
14
|
+
// index.ts, since it's an implementation detail of how this package's own reducers are built, not
|
|
15
|
+
// something a consuming app needs.
|
|
16
|
+
export function createActionCreator<P>(type: string): ActionCreator<P> {
|
|
17
|
+
const creator = ((payload: P) => ({ payload, type })) as ActionCreator<P>
|
|
18
|
+
creator.type = type
|
|
19
|
+
creator.match = (action: { type: string }): action is ReduxAction<P> => action.type === type
|
|
20
|
+
return creator
|
|
21
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
|
+
|
|
3
|
+
export interface SetProfileExtensionPayload<TExtension> {
|
|
4
|
+
extension: TExtension
|
|
5
|
+
id: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ProfileExtensionActions<TExtension> {
|
|
9
|
+
remove: ActionCreator<string>
|
|
10
|
+
set: ActionCreator<SetProfileExtensionPayload<TExtension>>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ProfileExtensionState<TExtension> = Record<string, TExtension>
|
|
14
|
+
|
|
15
|
+
export interface ProfileExtensionSlice<TExtension> {
|
|
16
|
+
actions: ProfileExtensionActions<TExtension>
|
|
17
|
+
reducer: (state: ProfileExtensionState<TExtension> | undefined, action: { type: string }) => ProfileExtensionState<TExtension>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A per-profile extension is any app-specific field that doesn't belong on the shared base Profile
|
|
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 — never synced to the shared App Group
|
|
23
|
+
// roster (a profile created on a sibling @tastic game has no idea this extension exists at all,
|
|
24
|
+
// same as this package's own README describes for any app-specific field). Two real @tastic games
|
|
25
|
+
// (BoxHockey's controlScheme, LightCycles' keyScheme) each hand-rolled an almost-identical
|
|
26
|
+
// Record<profileId, TExtension> reducer before this factory existed; this is that shape,
|
|
27
|
+
// generalized over the extension's own type so a future game's own per-profile preference doesn't
|
|
28
|
+
// need a third copy.
|
|
29
|
+
//
|
|
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 — same convention
|
|
32
|
+
// @rific/core's createSettingsSlice uses its own namespace argument for.
|
|
33
|
+
//
|
|
34
|
+
// Deliberately NOT auto-cleared when the base `profiles` slice removes an id — this stays a plain,
|
|
35
|
+
// self-contained reducer rather than importing profilesActions.remove's action type to listen for.
|
|
36
|
+
// An app already dispatches multiple independent actions when deleting a profile (profilesActions.
|
|
37
|
+
// remove, and typically its own local selection-slice cleanup); calling this slice's own `remove`
|
|
38
|
+
// alongside those is one more such dispatch, not new cross-slice wiring.
|
|
39
|
+
export function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension> {
|
|
40
|
+
const set = createActionCreator<SetProfileExtensionPayload<TExtension>>(`profileExtension/${namespace}/set`)
|
|
41
|
+
const remove = createActionCreator<string>(`profileExtension/${namespace}/remove`)
|
|
42
|
+
|
|
43
|
+
function reducer(state: ProfileExtensionState<TExtension> = {}, action: { type: string }): ProfileExtensionState<TExtension> {
|
|
44
|
+
if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension }
|
|
45
|
+
if (remove.match(action)) {
|
|
46
|
+
if (!(action.payload in state)) return state
|
|
47
|
+
const next = { ...state }
|
|
48
|
+
delete next[action.payload]
|
|
49
|
+
return next
|
|
50
|
+
}
|
|
51
|
+
return state
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { actions: { remove, set }, reducer }
|
|
55
|
+
}
|
|
@@ -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,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
|
+
}
|