@tastic/profile 0.1.2 → 0.3.1

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
@@ -54,6 +54,38 @@ see its own doc comment for why that component is generic over your own `Profile
54
54
  inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
55
55
  router, no back button) — a host app renders this as a routed screen's body and supplies its own
56
56
  persistence via `profiles`/`onCreate`/`onSave`/`onDelete`.
57
+ - **`loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`** — an *optional*
58
+ cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple
59
+ Developer Team, same `"com.apple.security.application-groups"` entitlement value on every
60
+ participating app). This only ever carries the base `Profile` shape above — a host app's own
61
+ extension fields (a key scheme, a control scheme, anything else) are NOT part of it and have to
62
+ keep living in that app's own *local* storage, keyed by profile id, merged onto the shared base
63
+ roster at read time (falling back to some per-app default whenever a shared profile's id isn't
64
+ in that app's own local extension table yet — e.g. it was created on a different app in the
65
+ group). This split is deliberate, not a limitation to work around: it's this package's own
66
+ base-fields-only `Profile` boundary (see above), just applied to storage instead of just to the
67
+ TypeScript type.
68
+
69
+ ```ts
70
+ // App-side sketch — see BoxHockey's or LightCycles' own useProfiles.tsx for a full example.
71
+ const GROUP_ID = 'group.com.yourteam.yourgames'
72
+ const base = isSharedProfileStoreAvailable ? await loadSharedProfiles(GROUP_ID) : localBaseFallback
73
+ const profiles = base.map((p) => ({ ...p, controlScheme: localExtensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME }))
74
+ // ...on create/update/delete, write the base fields back with saveSharedProfiles(GROUP_ID, nextBase)
75
+ // and the extension field to the app's own local storage, same as today.
76
+ ```
77
+
78
+ Backed by a small native Expo Module (`ios/TasticProfileModule.swift`) wrapping
79
+ `UserDefaults(suiteName:)` — iOS only (see `expo-module.config.json`'s `"apple"`-only platform
80
+ list); `isSharedProfileStoreAvailable` is `false` on Android/web and on any iOS build that hasn't
81
+ run `expo prebuild` since adding this App Group's entitlement, so a host app always needs its own
82
+ local-storage fallback path regardless of platform. Requires the App Group itself to be declared
83
+ in each consuming app's own Expo config, e.g.:
84
+
85
+ ```json
86
+ // app.json
87
+ { "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
88
+ ```
57
89
 
58
90
  ## Install (local dev via yalc)
59
91
 
package/dist/index.d.mts CHANGED
@@ -55,4 +55,8 @@ declare const MAX_TAG_LENGTH = 3;
55
55
  declare function isValidTag(value: unknown): value is string;
56
56
  declare function isValidProfile(value: unknown): value is Profile;
57
57
 
58
- export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isValidProfile, isValidTag };
58
+ declare const isSharedProfileStoreAvailable: boolean;
59
+ declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
60
+ declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
61
+
62
+ export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
package/dist/index.d.ts CHANGED
@@ -55,4 +55,8 @@ declare const MAX_TAG_LENGTH = 3;
55
55
  declare function isValidTag(value: unknown): value is string;
56
56
  declare function isValidProfile(value: unknown): value is Profile;
57
57
 
58
- export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isValidProfile, isValidTag };
58
+ declare const isSharedProfileStoreAvailable: boolean;
59
+ declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
60
+ declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
61
+
62
+ export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
package/dist/index.js CHANGED
@@ -25,8 +25,11 @@ __export(index_exports, {
25
25
  ProfileChip: () => ProfileChip,
26
26
  ProfilePicker: () => ProfilePicker,
27
27
  ProfilesManager: () => ProfilesManager,
28
+ isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
28
29
  isValidProfile: () => isValidProfile,
29
- isValidTag: () => isValidTag
30
+ isValidTag: () => isValidTag,
31
+ loadSharedProfiles: () => loadSharedProfiles,
32
+ saveSharedProfiles: () => saveSharedProfiles
30
33
  });
31
34
  module.exports = __toCommonJS(index_exports);
32
35
 
@@ -237,6 +240,7 @@ var styles2 = import_react_native2.StyleSheet.create({
237
240
  // src/ProfilesManager.tsx
238
241
  var import_auto_paper3 = require("@rific/auto-paper");
239
242
  var import_feedback_press2 = require("@rific/feedback-press");
243
+ var import_focus_chain = require("@rific/focus-chain");
240
244
  var import_toaster = require("@rific/toaster");
241
245
  var import_hud2 = require("@tastic/hud");
242
246
  var import_react2 = require("react");
@@ -383,12 +387,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
383
387
  ] });
384
388
  }
385
389
  function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
386
- const tagInputRef = (0, import_react2.useRef)(null);
387
- const nameInputRef = (0, import_react2.useRef)(null);
390
+ const register = (0, import_focus_chain.useFocusChain)();
391
+ const tag = register();
392
+ const name = register();
388
393
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
389
394
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, dark }),
390
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tagInputRef, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent, onSubmitEditing: () => nameInputRef.current?.focus() }),
391
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: nameInputRef, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput, onSubmitEditing: onSubmit }),
395
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
396
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
392
397
  onDelete && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
393
398
  ] });
394
399
  }
@@ -501,6 +506,40 @@ var styles3 = import_react_native3.StyleSheet.create({
501
506
  fontWeight: "bold"
502
507
  }
503
508
  });
509
+
510
+ // src/sharedProfileStore.ts
511
+ var native = null;
512
+ try {
513
+ const { requireOptionalNativeModule } = require("expo-modules-core");
514
+ native = requireOptionalNativeModule("TasticProfile") ?? null;
515
+ } catch {
516
+ native = null;
517
+ }
518
+ var PROFILES_KEY = "sharedProfiles";
519
+ var isSharedProfileStoreAvailable = native !== null;
520
+ async function loadSharedProfiles(groupId) {
521
+ if (!native) return [];
522
+ let json = null;
523
+ try {
524
+ json = await native.getSharedJSON(groupId, PROFILES_KEY);
525
+ } catch {
526
+ return [];
527
+ }
528
+ if (!json) return [];
529
+ try {
530
+ const parsed = JSON.parse(json);
531
+ return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
532
+ } catch {
533
+ return [];
534
+ }
535
+ }
536
+ async function saveSharedProfiles(groupId, profiles) {
537
+ if (!native) return;
538
+ try {
539
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
540
+ } catch {
541
+ }
542
+ }
504
543
  // Annotate the CommonJS export names for ESM import in node:
505
544
  0 && (module.exports = {
506
545
  MAX_PROFILE_NAME_LENGTH,
@@ -508,6 +547,9 @@ var styles3 = import_react_native3.StyleSheet.create({
508
547
  ProfileChip,
509
548
  ProfilePicker,
510
549
  ProfilesManager,
550
+ isSharedProfileStoreAvailable,
511
551
  isValidProfile,
512
- isValidTag
552
+ isValidTag,
553
+ loadSharedProfiles,
554
+ saveSharedProfiles
513
555
  });
package/dist/index.mjs CHANGED
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/ProfileChip.tsx
2
9
  import { getContrastColor } from "@rific/auto-paper";
3
10
  import { StyleSheet, View } from "react-native";
@@ -205,6 +212,7 @@ var styles2 = StyleSheet2.create({
205
212
  // src/ProfilesManager.tsx
206
213
  import { useAutoPaperTheme } from "@rific/auto-paper";
207
214
  import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
215
+ import { useFocusChain } from "@rific/focus-chain";
208
216
  import { useToast } from "@rific/toaster";
209
217
  import { InlineColorPicker, usePopoverHost } from "@tastic/hud";
210
218
  import { useCallback, useEffect, useMemo as useMemo2, useRef, useState } from "react";
@@ -351,12 +359,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
351
359
  ] });
352
360
  }
353
361
  function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
354
- const tagInputRef = useRef(null);
355
- const nameInputRef = useRef(null);
362
+ const register = useFocusChain();
363
+ const tag = register();
364
+ const name = register();
356
365
  return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
357
366
  /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, dark }),
358
- /* @__PURE__ */ jsx3(TextInput, { ref: tagInputRef, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent, onSubmitEditing: () => nameInputRef.current?.focus() }),
359
- /* @__PURE__ */ jsx3(TextInput, { ref: nameInputRef, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput, onSubmitEditing: onSubmit }),
367
+ /* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
368
+ /* @__PURE__ */ jsx3(TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
360
369
  onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
361
370
  ] });
362
371
  }
@@ -469,12 +478,49 @@ var styles3 = StyleSheet3.create({
469
478
  fontWeight: "bold"
470
479
  }
471
480
  });
481
+
482
+ // src/sharedProfileStore.ts
483
+ var native = null;
484
+ try {
485
+ const { requireOptionalNativeModule } = __require("expo-modules-core");
486
+ native = requireOptionalNativeModule("TasticProfile") ?? null;
487
+ } catch {
488
+ native = null;
489
+ }
490
+ var PROFILES_KEY = "sharedProfiles";
491
+ var isSharedProfileStoreAvailable = native !== null;
492
+ async function loadSharedProfiles(groupId) {
493
+ if (!native) return [];
494
+ let json = null;
495
+ try {
496
+ json = await native.getSharedJSON(groupId, PROFILES_KEY);
497
+ } catch {
498
+ return [];
499
+ }
500
+ if (!json) return [];
501
+ try {
502
+ const parsed = JSON.parse(json);
503
+ return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
504
+ } catch {
505
+ return [];
506
+ }
507
+ }
508
+ async function saveSharedProfiles(groupId, profiles) {
509
+ if (!native) return;
510
+ try {
511
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
512
+ } catch {
513
+ }
514
+ }
472
515
  export {
473
516
  MAX_PROFILE_NAME_LENGTH,
474
517
  MAX_TAG_LENGTH,
475
518
  ProfileChip,
476
519
  ProfilePicker,
477
520
  ProfilesManager,
521
+ isSharedProfileStoreAvailable,
478
522
  isValidProfile,
479
- isValidTag
523
+ isValidTag,
524
+ loadSharedProfiles,
525
+ saveSharedProfiles
480
526
  };
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["apple"],
3
+ "apple": {
4
+ "modules": ["TasticProfileModule"]
5
+ }
6
+ }
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'TasticProfile'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = { :ios => '16.4' }
14
+ s.swift_version = '5.9'
15
+ s.source = { git: package['repository']['url'] }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+
20
+ s.pod_target_xcconfig = {
21
+ 'DEFINES_MODULE' => 'YES',
22
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
23
+ }
24
+
25
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
26
+ end
@@ -0,0 +1,22 @@
1
+ import ExpoModulesCore
2
+
3
+ // Generic group/key -> JSON-string bridge onto a native iOS App Group's shared UserDefaults suite
4
+ // — the mechanism that actually lets two separate apps (same Apple Developer Team, both declaring
5
+ // the same "com.apple.security.application-groups" entitlement) read and write one common blob of
6
+ // data. Nothing here is @tastic/profile-specific — see src/sharedProfileStore.ts, the one caller,
7
+ // for the profile-roster-shaped API built on top of this. Android/web never register this module
8
+ // at all (see expo-module.config.json's "apple"-only platform list), so a consuming app has to
9
+ // treat "module not present" as a normal, expected case rather than an error.
10
+ public final class TasticProfileModule: Module {
11
+ public func definition() -> ModuleDefinition {
12
+ Name("TasticProfile")
13
+
14
+ AsyncFunction("getSharedJSON") { (groupId: String, key: String) -> String? in
15
+ UserDefaults(suiteName: groupId)?.string(forKey: key)
16
+ }
17
+
18
+ AsyncFunction("setSharedJSON") { (groupId: String, key: String, value: String) -> Void in
19
+ UserDefaults(suiteName: groupId)?.set(value, forKey: key)
20
+ }
21
+ }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.1.2",
3
+ "version": "0.3.1",
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",
@@ -39,13 +39,14 @@
39
39
  "files": [
40
40
  "dist",
41
41
  "src",
42
+ "ios",
43
+ "expo-module.config.json",
42
44
  "!src/__tests__",
43
45
  "!src/__mocks__"
44
46
  ],
45
47
  "scripts": {
46
48
  "build": "tsup src/index.ts --format cjs,esm --dts --clean",
47
49
  "build:watch": "tsup src/index.ts --format cjs,esm --dts --watch",
48
- "ci": "npm run lint && npm test && npm run typecheck && npm run build",
49
50
  "fix": "eslint --fix",
50
51
  "lint": "eslint",
51
52
  "prepublishOnly": "npm run build",
@@ -56,7 +57,8 @@
56
57
  "test": "jest",
57
58
  "test:watch": "jest --watchAll",
58
59
  "typecheck": "tsc --noEmit",
59
- "preversion": "npm run ci"
60
+ "verify": "npm run lint && npm test && npm run typecheck && npm run build",
61
+ "preversion": "npm ci && npm run verify"
60
62
  },
61
63
  "prettier": "@infinitetoken/eslint-config/prettier",
62
64
  "devDependencies": {
@@ -75,6 +77,7 @@
75
77
  "eslint": "^9.39.4",
76
78
  "eslint-plugin-react-hooks": "^7.1.1",
77
79
  "eslint-plugin-react-native": "^5.0.0",
80
+ "expo-modules-core": "^57.0.14",
78
81
  "jest": "^30.4.2",
79
82
  "jest-environment-jsdom": "^30.4.1",
80
83
  "prettier": "^3.8.3",
@@ -94,11 +97,17 @@
94
97
  "@rific/toaster": ">=0.4.0",
95
98
  "@tastic/core": ">=0.1.0",
96
99
  "@tastic/hud": ">=0.1.3",
100
+ "expo-modules-core": ">=1.0.0",
97
101
  "react": ">=19.0.0",
98
102
  "react-native": ">=0.76.0",
99
103
  "react-native-paper": ">=5.0.0",
100
104
  "react-native-safe-area-context": ">=4.0.0"
101
105
  },
106
+ "peerDependenciesMeta": {
107
+ "expo-modules-core": {
108
+ "optional": true
109
+ }
110
+ },
102
111
  "publishConfig": {
103
112
  "access": "public"
104
113
  }
@@ -1,9 +1,10 @@
1
1
  import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
+ import { useFocusChain } from '@rific/focus-chain'
3
4
  import { useToast } from '@rific/toaster'
4
5
  import { InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
5
- import { ComponentRef, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
- import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TextInput as RNTextInput, View } from 'react-native'
6
+ import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
+ import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'
7
8
  import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
8
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
9
10
 
@@ -275,21 +276,26 @@ interface EditRowProps {
275
276
  // color trigger here — it keeps its plain palette icon regardless of the row's own tag field, since
276
277
  // the tag is already right next to it in its own field, so repeating it on the color trigger too
277
278
  // would be redundant rather than informative.
278
- // Return-key chains tag -> name via explicit refs rather than each field submitting on its
279
- // own — submitting the *name* field is what actually commits the row (see the last field's own
280
- // onSubmitEditing override below), matching how a normal multi-field form reads: fill fields in
281
- // order, the last one finishes it.
279
+ // Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
280
+ // own — submitting the *name* field is what actually commits the row, so its registration's own
281
+ // onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
282
+ // how a normal multi-field form reads: fill fields in order, the last one finishes it.
282
283
  function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
283
- const tagInputRef = useRef<ComponentRef<typeof RNTextInput>>(null)
284
- const nameInputRef = useRef<ComponentRef<typeof RNTextInput>>(null)
284
+ const register = useFocusChain()
285
+ const tag = register()
286
+ const name = register()
285
287
 
286
288
  return (
287
289
  <View style={styles.editRow}>
288
290
  <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} dark={dark} />
289
291
  {/* selectTextOnFocus so tapping into an already-set tag selects it for wholesale replacement
290
292
  — the far more common edit than inserting into the middle of a 1-3 character value. */}
291
- <TextInput ref={tagInputRef} mode='outlined' dense value={draftTag} onChangeText={onTagChange} placeholder='Tag' autoCapitalize='characters' selectTextOnFocus error={tagInvalid} returnKeyType='next' style={styles.tagInput} contentStyle={styles.tagInputContent} onSubmitEditing={() => nameInputRef.current?.focus()} />
292
- <TextInput ref={nameInputRef} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} onSubmitEditing={onSubmit} />
293
+ {/* eslint-disable-next-line react-hooks/refs -- tag.ref/tag.props come from useFocusChain's
294
+ register(), called during render by design (see the hook's own doc); the actual DOM/native
295
+ focus() call it wraps only ever fires later, from an event handler, never synchronously here */}
296
+ <TextInput ref={tag.ref} {...tag.props} mode='outlined' dense value={draftTag} onChangeText={onTagChange} placeholder='Tag' autoCapitalize='characters' selectTextOnFocus error={tagInvalid} returnKeyType='next' style={styles.tagInput} contentStyle={styles.tagInputContent} />
297
+ {/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
298
+ <TextInput ref={name.ref} {...name.props} onSubmitEditing={onSubmit} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} />
293
299
  {onDelete && (
294
300
  <TouchableRipple onPress={onDelete} style={styles.iconButton}>
295
301
  <Icon source='trash-can-outline' size={18} color={fgMuted} />
package/src/index.ts CHANGED
@@ -2,4 +2,5 @@ export { ProfileChip } from './ProfileChip'
2
2
  export { ProfilePicker } from './ProfilePicker'
3
3
  export { ProfilesManager, type ProfilesManagerProps } from './ProfilesManager'
4
4
  export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
5
+ export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
5
6
  export type { Profile } from './types'
@@ -0,0 +1,65 @@
1
+ import { isValidProfile } from './profilesValidation'
2
+ import { Profile } from './types'
3
+
4
+ interface NativeTasticProfile {
5
+ getSharedJSON(groupId: string, key: string): Promise<string | null>
6
+ setSharedJSON(groupId: string, key: string, value: string): Promise<void>
7
+ }
8
+
9
+ // Lazily/optionally resolved — this package has no hard dependency on expo-modules-core (it's a
10
+ // peer, provided by whatever Expo app consumes it), and the native module itself only registers on
11
+ // iOS (see ios/TasticProfileModule.swift and expo-module.config.json's "apple"-only platform list).
12
+ // Guarding both the require and the lookup means Android/web, and an iOS build that hasn't run
13
+ // `expo prebuild` since adding this package yet, both degrade to "sharing unavailable" instead of a
14
+ // hard crash.
15
+ let native: NativeTasticProfile | null = null
16
+ try {
17
+ const { requireOptionalNativeModule } = require('expo-modules-core')
18
+ native = (requireOptionalNativeModule('TasticProfile') ?? null) as NativeTasticProfile | null
19
+ } catch {
20
+ native = null
21
+ }
22
+
23
+ const PROFILES_KEY = 'sharedProfiles'
24
+
25
+ // True once at module load — a consuming app uses this to decide whether to fall back to its own
26
+ // local-only storage for the base roster too (see this package's README's App Group section).
27
+ export const isSharedProfileStoreAvailable = native !== null
28
+
29
+ // Reads the shared base-identity roster out of a given App Group suite. Always resolves to an
30
+ // array, never null/throws — an unavailable store, a missing/empty blob, and a corrupt blob all
31
+ // read the same as "nothing shared yet." Entries that fail isValidProfile are dropped individually
32
+ // rather than invalidating the whole roster, since the base fields validated here are the ONLY
33
+ // thing this store is a source of truth for — a host app's own extension fields (a control scheme,
34
+ // anything else) never travel through it at all; see the README for the base/extension split this
35
+ // is meant to be used with.
36
+ export async function loadSharedProfiles(groupId: string): Promise<Profile[]> {
37
+ if (!native) return []
38
+ let json: string | null = null
39
+ try {
40
+ json = await native.getSharedJSON(groupId, PROFILES_KEY)
41
+ } catch {
42
+ return []
43
+ }
44
+ if (!json) return []
45
+ try {
46
+ const parsed: unknown = JSON.parse(json)
47
+ return Array.isArray(parsed) ? parsed.filter(isValidProfile) : []
48
+ } catch {
49
+ return []
50
+ }
51
+ }
52
+
53
+ // Overwrites the entire shared roster — same whole-blob-write discipline a host app's own
54
+ // AsyncStorage-backed profile store already uses, not an incremental per-profile update. A write
55
+ // failure (store unavailable, or some other native error) is swallowed rather than thrown: the
56
+ // caller's own local state is already the source of truth for what just changed, and this is a
57
+ // best-effort mirror of it, not the other way around.
58
+ export async function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void> {
59
+ if (!native) return
60
+ try {
61
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles))
62
+ } catch {
63
+ // Best-effort — see doc comment above.
64
+ }
65
+ }