@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 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
- // App-side sketch — see BoxHockey's or LightCycles' own useProfiles.tsx for a full example.
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
- ## Install (local dev via yalc)
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
- Not published to the public npm registry yet.
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`) — none of these
113
- are bundled, so use whatever versions your app already has.
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
- saveSharedProfiles: () => saveSharedProfiles
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
- saveSharedProfiles
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
- saveSharedProfiles
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.8",
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.10.3",
69
- "@rific/feedback-press": "^0.10.8",
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.6.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
+ }