@marktiderman/genesis-native 0.1.0 → 0.3.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.
Files changed (87) hide show
  1. package/README.md +354 -10
  2. package/dist/block-C5-noMhB.d.ts +223 -0
  3. package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-VKTIHNAV.js +95 -0
  13. package/dist/chunk-VKTIHNAV.js.map +1 -0
  14. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  15. package/dist/chunk-VTJDIKFC.js.map +1 -0
  16. package/dist/chunk-XE7LC7K6.js +29 -0
  17. package/dist/chunk-XE7LC7K6.js.map +1 -0
  18. package/dist/dev-warnings.d.ts +34 -0
  19. package/dist/dev-warnings.js +15 -0
  20. package/dist/dev-warnings.js.map +1 -0
  21. package/dist/devtools.d.ts +144 -0
  22. package/dist/devtools.js +390 -0
  23. package/dist/devtools.js.map +1 -0
  24. package/dist/index.d.ts +12 -368
  25. package/dist/index.js +88 -191
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +170 -0
  28. package/dist/observability.js +123 -0
  29. package/dist/observability.js.map +1 -0
  30. package/dist/persist.d.ts +1 -1
  31. package/dist/persist.js +1 -1
  32. package/dist/push.d.ts +169 -0
  33. package/dist/push.js +996 -0
  34. package/dist/push.js.map +1 -0
  35. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  36. package/dist/updates.d.ts +8 -1
  37. package/dist/updates.js +44 -69
  38. package/dist/updates.js.map +1 -1
  39. package/package.json +222 -5
  40. package/src/app.tsx +491 -0
  41. package/src/auth.tsx +176 -0
  42. package/src/blocks.tsx +144 -0
  43. package/src/boot.tsx +188 -0
  44. package/src/dev-warnings.ts +43 -0
  45. package/src/devtools/block.tsx +488 -0
  46. package/src/devtools/gestures.ts +107 -0
  47. package/src/devtools/index.ts +22 -0
  48. package/src/devtools/info.ts +45 -0
  49. package/src/devtools/slots.ts +21 -0
  50. package/src/errors.tsx +195 -0
  51. package/src/held-link.ts +90 -0
  52. package/src/hydration-gate.ts +88 -0
  53. package/src/index.ts +83 -0
  54. package/src/links.ts +140 -0
  55. package/src/nativewind.d.ts +41 -0
  56. package/src/observability/block.tsx +300 -0
  57. package/src/observability/events.ts +89 -0
  58. package/src/observability/index.ts +44 -0
  59. package/src/observability/scrub.ts +166 -0
  60. package/src/observability/sentry.ts +222 -0
  61. package/src/observability/sink.ts +147 -0
  62. package/src/persist.ts +83 -0
  63. package/src/push/backend.ts +129 -0
  64. package/src/push/block.tsx +545 -0
  65. package/src/push/index.ts +102 -0
  66. package/src/push/modules.ts +111 -0
  67. package/src/push/permission.ts +191 -0
  68. package/src/push/preferences.ts +136 -0
  69. package/src/push/quiet-hours.ts +71 -0
  70. package/src/push/registration.ts +189 -0
  71. package/src/push/routing.ts +27 -0
  72. package/src/push/screens.tsx +46 -0
  73. package/src/push/settings.tsx +378 -0
  74. package/src/query.ts +100 -0
  75. package/src/updates/block.tsx +442 -0
  76. package/src/updates/channels.ts +131 -0
  77. package/src/updates/checker.ts +190 -0
  78. package/src/updates/index.ts +87 -0
  79. package/src/updates/modules.ts +64 -0
  80. package/src/updates/policy.ts +314 -0
  81. package/src/updates/screens.tsx +159 -0
  82. package/src/updates/signature.ts +80 -0
  83. package/src/updates/store.ts +33 -0
  84. package/src/updates/version.ts +39 -0
  85. package/src/updates/whats-new.ts +122 -0
  86. package/dist/block-Dh5ny06W.d.ts +0 -535
  87. package/dist/chunk-MMTPWUWI.js.map +0 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@ everything above your screens, so the root layout is a few lines:
6
6
  ```tsx
7
7
  // app/_layout.tsx
8
8
  import * as SplashScreen from "expo-splash-screen";
9
- import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
9
+ import { KeyboardAwareScrollView, KeyboardProvider } from "react-native-keyboard-controller";
10
10
  import { Stack, router } from "expo-router";
11
11
  import { GenesisNativeApp } from "@marktiderman/genesis-native";
12
12
  import { brand, supabase, steps, persist, linkHold } from "../lib/app";
@@ -16,7 +16,7 @@ SplashScreen.preventAutoHideAsync();
16
16
  export default function RootLayout() {
17
17
  return (
18
18
  <GenesisNativeApp brand={brand} supabase={supabase} splash={SplashScreen} steps={steps} persist={persist}
19
- keyboardScroll={KeyboardAwareScrollView} links={{ hold: linkHold, onRelease: (path) => router.replace(path) }}>
19
+ keyboardProvider={KeyboardProvider} keyboardScroll={KeyboardAwareScrollView} links={{ hold: linkHold, onRelease: (path) => router.replace(path) }}>
20
20
  <Stack />
21
21
  </GenesisNativeApp>
22
22
  );
@@ -36,8 +36,21 @@ From the outside in:
36
36
  `fatal` slot itself throws, this is what shows, and "Try again" starts over.
37
37
  2. **Theme**: `GenesisThemeProvider` with `syncNativeAppearance` on, so the
38
38
  root owns the platform color scheme.
39
- 3. **Keyboard**: `KeyboardScrollProvider` with your `keyboardScroll`, so
40
- Genesis form screens scroll the focused field into view.
39
+ 3. **Keyboard**: your `keyboardProvider` (react-native-keyboard-controller's
40
+ `KeyboardProvider`), mounted once, then `KeyboardScrollProvider` with your
41
+ `keyboardScroll`, so Genesis form screens scroll the focused field into
42
+ view. The scroll view does nothing without the provider: give both. In
43
+ development, `keyboardScroll` without `keyboardProvider` logs an error.
44
+ `keyboardToolbar` (`KeyboardToolbar`) adds the previous / next / done bar
45
+ above the keyboard on Genesis's form layouts (`AuthScreen`,
46
+ `SettingsSubPage`); it needs `keyboardScroll`, and logs an error in
47
+ development without it.
48
+ Then `modules`, the optional native modules your app installed
49
+ (`GenesisModulesProvider` from `@marktiderman/genesis-ui-native`: FlashList,
50
+ expo-image, Reanimated, expo-glass-effect, expo-symbols, a lucide icon
51
+ set, the @expo/ui date picker). Each one you leave out falls back to plain
52
+ React Native. Read once. The update banner is Liquid Glass on iOS 26 when
53
+ `modules.glass` is given. See that package's README.
41
54
  4. **Data**: `GenesisProvider` over `createSupabaseProvider(supabase)` (or
42
55
  your `provider`) and the shell's QueryClient.
43
56
  5. **Boot**: your `steps` (wipe a session left by a reinstall, hydrate stores,
@@ -55,16 +68,21 @@ From the outside in:
55
68
  while the fatal screen is up goes to React Native's default handler, which
56
69
  ends the app so it restarts clean. Unhandled promise rejections never reach
57
70
  `ErrorUtils` in React Native, so on Hermes the shell installs its rejection
58
- tracker (`rejectionTracker`, `null` turns it off). `onError` gets all of
59
- these, for reporting.
71
+ tracker (`rejectionTracker`, `null` turns it off). Every block's `onError`
72
+ (the observability block's, which reports to Sentry) and then your
73
+ `onError` get all of these; a reporter that throws is ignored.
60
74
  7. **Session, offline included**: `useSession()` is `loading`, `signedIn` or
61
75
  `signedOut`, with `userId` and `offline`. Offline with an expired access
62
76
  token, Supabase's `getSession()` can spend about 30 seconds on a refresh
63
77
  and then fail; the shell waits at most `sessionWaitMs` (5 s) and on a
64
78
  timeout or a network error carries on with the session stored on the
65
79
  phone (`offline: true`). Supabase's own events correct it once online.
66
- 8. **Query**: `networkMode: "always"` so offline queries fail instead of
67
- hanging. With `persist`, the query-key roots you list are saved to the
80
+ 8. **Query**: `networkMode: "offlineFirst"`: a query or mutation runs once,
81
+ then its retries pause while NetInfo says offline and continue when the
82
+ network is back, and stale queries refetch on reconnect. Offline, saved
83
+ data stays on screen; a query with nothing saved is `pending` with
84
+ `isPaused` (show "offline", not a spinner); a mutation fails at once (no
85
+ retries by default). With `persist`, the query-key roots you list are saved to the
68
86
  phone for 24 hours (`maxAge`), discarded when `buster` (your app version)
69
87
  changes, and **scoped to the signed-in user**: the user id is part of what
70
88
  is saved, a cache saved for anyone else is deleted instead of restored, and
@@ -116,6 +134,31 @@ From the outside in:
116
134
  A path the map doesn't know, or one with a `.` or `..` segment, goes to
117
135
  `"/"` and `onUnknown`; `ignore` patterns return `null` so the router
118
136
  leaves them alone (an auth callback).
137
+ 11. **Sheets**: `NativeSheet` (what's new, the push prompt) is React Native's
138
+ own `Modal` and needs nothing above it: the system page sheet on iOS, a
139
+ bottom panel on Android. To use `@gorhom/bottom-sheet` instead (snap
140
+ points), pass it: `import * as BottomSheet from "@gorhom/bottom-sheet"`,
141
+ then `bottomSheet={BottomSheet}`. The shell mounts its
142
+ `BottomSheetModalProvider` inside the data, session and blocks providers,
143
+ so sheet content can read them. The library also needs gesture-handler's
144
+ `GestureHandlerRootView` around the app and Reanimated set up; without
145
+ them it throws when a sheet opens.
146
+ 12. **Toasts**: `NativeToastProvider` is mounted around the screens, so
147
+ `useToast()` from genesis-ui-native works on any screen. Toasts show at
148
+ the top (a bottom toast would sit on a tab bar), below the safe area;
149
+ `toast={{ position: "bottom" }}` moves them, and `toast={null}` leaves
150
+ the provider to the app (one the app mounts inside the shell is the one
151
+ `useToast()` finds either way).
152
+ 13. **Development warnings**: in React Native 0.88 the first `console.warn`
153
+ pins LogBox's "Open debugger to view warnings." pill over the bottom of
154
+ the screen, where a native tab bar's first tap lands on it. The warnings
155
+ the app can't act on (React Native's `ImageBackground` deprecation, set
156
+ off by NativeWind's runtime) are kept out by
157
+ `ignoreKnownDependencyWarnings(LogBox, __DEV__)` from
158
+ `@marktiderman/genesis-native/dev-warnings`, called from the app's entry
159
+ file before `require("expo-router/entry")` (it has to run before any
160
+ library loads; see the starter's `index.ts`). It removes that trigger
161
+ only: any other warning, the app's own included, still shows the pill.
119
162
 
120
163
  ## Slots
121
164
 
@@ -131,6 +174,7 @@ From the outside in:
131
174
  | `maintenance` | `MaintenanceScreen` (with `updates`) | `{ message, retry, retrying }` |
132
175
  | `updateBanner` | `UpdateBanner` (with `updates`) | `{ reload, dismiss, canReload, blocked, reloading }` |
133
176
  | `whatsNew` | `WhatsNewSheet` (with `updates`) | `{ notes, version, dismiss }` |
177
+ | `devtoolsFeedback` | nothing (in the devtools overlay; the support block fills it) | `{ debugInfo, close }` (`close()` resolves once the sheet has slid away) |
134
178
 
135
179
  The last four render only when the `updates` prop is given. Ejecting a
136
180
  blocking one (`forceUpgrade: null`, `maintenance: null`) shows your screens
@@ -230,6 +274,303 @@ imported: without `updates` there is no banner and no channel switching;
230
274
  without `application` nothing is forced and no notes show. The banner uses
231
275
  `react-native-safe-area-context`, a peer (genesis-ui-native needs it too).
232
276
 
277
+ ## Observability: errors, breadcrumbs, events, timings
278
+
279
+ `@marktiderman/genesis-native/observability` is the observability block. It is vendor-neutral:
280
+ everything goes through sinks, and the Sentry one is an adapter.
281
+
282
+ ```ts
283
+ // lib/observability.ts: imported first by the module that builds GenesisNativeApp's props
284
+ import * as Sentry from "@sentry/react-native";
285
+ import * as Application from "expo-application";
286
+ import * as Updates from "expo-updates";
287
+ import { useSegments } from "expo-router";
288
+ import { Platform } from "react-native";
289
+ import { consoleSink, createObservability, defineEvents, sentrySink } from "@marktiderman/genesis-native/observability";
290
+ import { versionSignature } from "@marktiderman/genesis-native/updates";
291
+
292
+ export const events = defineEvents({
293
+ "auth.signed_in": { method: ["email", "apple"] },
294
+ "checkin.saved": { mood: "number", first: "boolean" },
295
+ });
296
+
297
+ export const observability = createObservability({
298
+ events,
299
+ useSegments,
300
+ sinks: [
301
+ __DEV__ && consoleSink(),
302
+ sentrySink(Sentry, {
303
+ dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
304
+ signature: versionSignature({ application: Application, updates: Updates, platform: Platform.OS }),
305
+ applicationId: Application.applicationId,
306
+ }),
307
+ ],
308
+ });
309
+
310
+ // app/_layout.tsx
311
+ function RootLayout() {
312
+ return <GenesisNativeApp {...shell} blocks={[observability]}>…</GenesisNativeApp>;
313
+ }
314
+ export default observability.wrap(RootLayout); // Sentry.wrap: touch and profiler boundaries
315
+ ```
316
+
317
+ - **One error path.** Every error the shell catches (a render error in either boundary, a global
318
+ error, a failed boot, an unhandled rejection) goes to the block's `onError` and then to yours.
319
+ A sink that throws is reported to `onProblem` and never stops the other sinks or the fatal screen.
320
+ `observability.captureError(error, { source: "sync" })` reports one you caught yourself.
321
+ - **Init order.** `sentrySink` runs `Sentry.init` when it is called, so build the block at module
322
+ scope, in a module the root layout imports before anything else: errors while the app's modules
323
+ load are caught too. A second call in the same process (Fast Refresh) reuses the client.
324
+ Sentry's own global handler stays installed for errors before the shell mounts; once the shell
325
+ is up, its handler runs first and hands the same `Error` object on, which Sentry's deduplication
326
+ (`__sentry_captured__`) sends once. A thrown value that isn't an `Error` can be reported twice.
327
+ - **PII is scrubbed on the device, by default.** Emails (percent-encoded too), phone numbers,
328
+ JWTs (Supabase access tokens and keys), Supabase `sb_secret_` / `sb_publishable_` keys, bearer
329
+ tokens, tokens in query strings, row values in PostgreSQL errors (`Failing row contains (…)`)
330
+ and long hex secrets are replaced in every string; values under keys naming a secret or a
331
+ contact detail (`authorization`, `cookie`, `apikey`, anything with `token`, `secret`, `email` or
332
+ `phone` in it) are replaced whole; every URL (web, deep link or path) loses its query and
333
+ fragment (PostgREST filters carry record ids); request bodies, cookies and PostgREST `details`
334
+ are dropped. Strings are cut at 4 KB first, so a huge log can't stall the JS thread. Sentry's
335
+ own ids (`event_id`, `trace_id`, `debug_id`, …) and processing metadata are left alone, since
336
+ its envelope is built from them. Sentry gets the scrubber in `beforeSend`,
337
+ `beforeSendTransaction`, `beforeSendSpan`, `beforeBreadcrumb`, `beforeSendLog` and
338
+ `beforeSendMetric` (yours run after, on the scrubbed value), plus `sendDefaultPii: false`, no
339
+ screenshots, view hierarchy or accessibility labels, and touch breadcrumbs without the touched
340
+ element's label. The user is an id only. If scrubbing throws, the event is dropped. Other sinks
341
+ get scrubbed errors, breadcrumbs and tags.
342
+ - **What it can't catch:** a person's name in free text (an error message that says "Jane
343
+ Doe"), and native crashes, which sentry-cocoa and sentry-android build without these
344
+ JavaScript hooks. Keep names out of messages; use ids.
345
+ - **Typed analytics.** `observability.track("auth.signed_in", { method: "apple" })` is checked
346
+ against `defineEvents` at compile time and at run time. A prop is a closed list of strings, a
347
+ number or a boolean, never free text; an unknown event, prop or value is dropped and reported.
348
+ - **Routes.** With `useSegments` from expo-router, each route change leaves a `navigation`
349
+ breadcrumb and a `route.time_on_screen` timing for the route left, by route pattern
350
+ (`/(app)/clients/[id]`), never the values in it. Sentry's `expoRouterIntegration` (on by
351
+ default in `sentrySink`) records its own navigation spans and time to initial display, so the
352
+ Sentry sink skips the block's navigation breadcrumbs.
353
+ - **Crashes count.** A fatal error (the fatal screen, a failed boot) is sent as unhandled,
354
+ so Sentry's release health counts it as a crash; a non-fatal one as handled.
355
+ - **Environments.** `development` in `__DEV__`; otherwise one per kind of build from the channel:
356
+ `production`, `preview` (`preview` and `pr-<number>`), `development`. Override with `environment`.
357
+ - **Releases.** `sentrySink` sets `release` to `<applicationId>@<version>+<build>` and `dist` to the
358
+ build (Sentry's own format) from the version signature, and tags every event with
359
+ `update.id`, `update.channel`, `update.runtime` and `update.launch`, so an over-the-air update is
360
+ told apart from the binary it runs on. Without an application id or version, it leaves the
361
+ release to Sentry rather than guess.
362
+ - **Without a DSN** `sentrySink` starts nothing (a warning outside development).
363
+ - `useRecentActivity()` gives the last breadcrumbs and errors, scrubbed (the devtools overlay
364
+ shows them).
365
+
366
+ **Source maps (Sentry).** Optional: add the `@sentry/react-native/expo` config plugin with your
367
+ organization and project, put `SENTRY_AUTH_TOKEN` in EAS secrets, use `getSentryExpoConfig` from
368
+ `@sentry/react-native/metro` in `metro.config.js` (Debug IDs), and for `eas update` run
369
+ `npx @sentry/expo-upload-sourcemaps dist` after the update. In this monorepo also set
370
+ `"@sentry/cli": true` under `allowBuilds` in `pnpm-workspace.yaml`, so the upload binary installs.
371
+
372
+ **EAS Observe.** EAS Observe (`expo-observe`) measures cold and warm launch, bundle load, time to
373
+ first render and EAS Update download time on its own once the root is wrapped
374
+ (`ObserveRoot.wrap`); time to interactive when you call `useObserve().markInteractive()` on your
375
+ entry screens; and, with `Observe.configure({ integrations: { "expo-router": true } })` at module
376
+ scope, per-route render and interactive times. It has no API for a custom timing metric, so
377
+ `observeSink(Observe)` sends `timing` as an event (`timing.<name>` with `duration_ms`, on the
378
+ Events page) and analytics events with `Observe.logEvent`. Its error reporting is in preview; the
379
+ sink leaves errors to Sentry unless `errors: true`.
380
+
381
+ `@sentry/react-native` and `expo-observe` are optional peers, passed in rather than imported, so an
382
+ app without them never bundles them.
383
+
384
+ ## Devtools overlay
385
+
386
+ `@marktiderman/genesis-native/devtools` is a debug sheet for development, preview and internal
387
+ builds. It opens with a three-finger hold (every build, no native module), a shake (pass
388
+ `accelerometer: Accelerometer` from expo-sensors), or "Open devtools" in React Native's dev menu
389
+ (`devSettings: DevSettings`), and shows:
390
+
391
+ - the version signature, the update channel and a channel switch (the updates block's guarded
392
+ `channels`: off where that block has it off; "Back to the build's channel" always works);
393
+ - the signed-in user's id, the server's base URL, the blocks in effect (`useBlocks`) and your flags;
394
+ - the recent errors and breadcrumbs from the observability block (scrubbed);
395
+ - "Copy debug info" (your `copy`, else the share sheet), "Crash test" (a render error, to check the
396
+ fatal screen and reporting end to end), "Clear cache" (the query cache, then your `clearCache`)
397
+ and "Sign out" (your `signOut`);
398
+ - the `devtoolsFeedback` slot, which the support block fills with "Send feedback" (an annotated
399
+ screenshot); empty by default. `close()` (the block's, and the slot's) resolves once the sheet
400
+ has finished sliding away (its `onDismiss`, iOS), or after 800 ms where the platform doesn't
401
+ report it (Android), so a screenshot taken after it shows the app, not the sheet.
402
+
403
+ **Keep it out of production.** Import the entry only behind a condition Metro folds at build time.
404
+ Metro inlines `__DEV__`, babel-preset-expo inlines `process.env.EXPO_PUBLIC_*`, and in a
405
+ production bundle the dead branch is dropped before dependencies are collected, so the overlay's
406
+ code isn't in the bundle at all:
407
+
408
+ ```ts
409
+ // lib/devtools.ts: the condition must be written out here (a variable or a helper stops the folding)
410
+ export const devtools =
411
+ __DEV__ || process.env.EXPO_PUBLIC_APP_VARIANT === "development" || process.env.EXPO_PUBLIC_APP_VARIANT === "preview"
412
+ ? require("./devtools-overlay").createAppDevtools()
413
+ : undefined;
414
+
415
+ // lib/devtools-overlay.ts
416
+ export const createAppDevtools = () =>
417
+ createDevtools({ enabled: __DEV__ || /\.(dev|preview)$/.test(Application.applicationId ?? ""), updates, observability, … });
418
+ ```
419
+
420
+ Set `EXPO_PUBLIC_APP_VARIANT` wherever `APP_VARIANT` is set (eas.json build profiles and the EAS
421
+ workflow jobs do, in `templates/eas`); unset, the overlay is left out. `enabled` is the second
422
+ lock, a fact of the binary: with it false the block mounts nothing and listens to nothing.
423
+ ## Push: registration, taps, the badge, notification settings
424
+
425
+ `@marktiderman/genesis-native/push` is the push block, a `defineBlock`
426
+ declaration (`id: "push"`). Build it once and put it in `blocks`; taking it
427
+ out turns it off. It contributes a Settings row (`useBlockSettings`: title
428
+ "Notifications", section `notifications`, href `/settings/notifications`, the
429
+ permission as its subtitle; `settingsRow` changes or drops it) and a boot step
430
+ that reads the permission behind the splash (a second at most; it never fails
431
+ the boot), and names its migration. It runs on migration
432
+ 0005 and the `genesis-push-send` function of
433
+ [`@marktiderman/genesis-supabase`](../supabase/README.md#push-the-push-block).
434
+
435
+ ```ts
436
+ // lib/push.ts
437
+ import * as Notifications from "expo-notifications";
438
+ import * as Application from "expo-application";
439
+ import Constants from "expo-constants";
440
+ import AsyncStorage from "@react-native-async-storage/async-storage";
441
+ import { router } from "expo-router";
442
+ import { AppState, Linking, Platform } from "react-native";
443
+ import { createPush, supabasePushBackend } from "@marktiderman/genesis-native/push";
444
+
445
+ export const push = createPush({
446
+ notifications: Notifications,
447
+ platform: Platform.OS,
448
+ projectId: Constants.expoConfig?.extra?.eas?.projectId,
449
+ appVersion: Application.nativeApplicationVersion,
450
+ backend: supabasePushBackend(supabase),
451
+ categories: [{ id: "messages", title: "Messages" }, { id: "digest", title: "Weekly digest", defaultOn: false }],
452
+ storage: AsyncStorage,
453
+ appState: AppState,
454
+ openSettings: Linking.openSettings,
455
+ links, // the same LinkOptions as +native-intent
456
+ hold: linkHold, // the same hold as GenesisNativeApp's links
457
+ onOpen: (path) => router.push(path),
458
+ });
459
+
460
+ // app/_layout.tsx
461
+ <GenesisNativeApp blocks={[push]} links={{ hold: linkHold, onRelease: router.replace }} … />
462
+
463
+ // app/settings/notifications.tsx
464
+ <NotificationSettings />
465
+ ```
466
+
467
+ - **Permission, asked well.** The block never asks at launch. Call
468
+ `usePushPrompt().offer("when someone replies")` at the moment push is worth
469
+ it; the `pushPrompt` slot (default `PushPromptSheet`) says why first, and only
470
+ its "Turn on" brings up the system prompt. `offer` is ignored for
471
+ `prompt.launchGraceMs` (10 s) after start (so offer on a screen's focus, not
472
+ only its mount), while signed out, once the system
473
+ can't ask again, for `cooldownMs` (3 days) after "Not now", and for good
474
+ after `maxDeclines` (3). A tap on "Allow notifications" in
475
+ `NotificationSettings` asks straight away (the tap is the intent); once the
476
+ system won't ask, it opens Settings (`openSettings`). iOS provisional
477
+ permission counts as granted.
478
+ - **The token follows the session.** Once permission is granted, a signed-in
479
+ person's Expo push token (`getExpoPushTokenAsync({ projectId })`) is
480
+ registered with `genesis_push_register`, again when the OS rotates it
481
+ (`addPushTokenListener`) and at most hourly otherwise. Sign-out, an account
482
+ switch and deleting the account forget it (`genesis_push_forget`, which works
483
+ without a session: holding the token is the proof); a forget that fails
484
+ offline is kept on the phone and retried each time the app comes to the
485
+ front, and never undoes a later registration of the same token. Coming to the
486
+ front also registers again when the last try failed, and at most hourly
487
+ otherwise (so a token another login took comes back). Without a `projectId` there is no
488
+ token: the block says so once through `onError`, and Settings says so on
489
+ screen.
490
+ - **Android channels.** A `default` channel, one per category (named after it,
491
+ so a person can mute a category in Android's own settings) and any
492
+ `channels` you add, all with private lock-screen visibility, made before the
493
+ permission prompt and the token (Android 13 shows its prompt only once a
494
+ channel exists). The sender uses the category as the channel id.
495
+ - **While the app is open** the foreground handler shows a notification
496
+ (banner, list, sound, badge) unless `foreground(notification)` says no or a
497
+ screen hides its kind: `useSuppressNotifications((data) => data.threadId === id)`.
498
+ Signed out, nothing shows.
499
+ - **Taps.** `data.url` of a tapped notification (and of the one that launched
500
+ the app, `getLastNotificationResponse`) goes through `normalizeLink` with
501
+ `links`, once per notification: an unknown path opens `/`, another app's URL
502
+ never opens. Signed in, `onOpen(path)` runs after the navigator mounts;
503
+ signed out, the path is held in `hold` (the same `linkHold` as
504
+ GenesisNativeApp's `links.hold`) and followed after sign-in. Without `onOpen`
505
+ or `hold`, a tap opens nothing and says so through `onError`. Action buttons
506
+ go to `onResponse`.
507
+ - **Badge.** `useBadge().set(n)` / `clear()`; the sender can set it too. It is
508
+ cleared on sign-out (`clearBadgeOnForeground` clears it on every return to
509
+ the front).
510
+ - **NotificationSettings** is a `SettingsPage` with four parts, each a slot and
511
+ a hook: `permission` (`usePushPermission`), `categories`
512
+ (`useNotificationCategories`, a switch per category), `quietHours`
513
+ (`useQuietHours`: a switch, from and to times with `NativeTimePicker`, and
514
+ the time zone; turning it on fills in the phone's zone, and a phone that has
515
+ travelled offers its new one) and `privacy` (`usePrivacyPreviews`: "Show
516
+ previews"). `slots={{ quietHours: Mine }}` renders yours in the same section
517
+ with the same props; `null` drops the section. Nothing can be changed until
518
+ the saved settings have loaded (a change on top of the defaults would save
519
+ them over the person's choices); a failed load says so and is tried again
520
+ when the screen opens. Changes save at once and are put back, with a note,
521
+ when the save fails. The words are
522
+ `notificationSettingsCopy`, replaced with `copy`. The time pickers need
523
+ `@react-native-community/datetimepicker`; without it the times are shown, not
524
+ editable.
525
+ - **What the server decides.** Quiet hours and hidden previews are applied by
526
+ the sender (`genesis_push_targets`), in the person's saved time zone, so a
527
+ phone with a wrong clock can't change them, and hidden previews never leave
528
+ the server. The phone only shows them (`isQuietAt`, by time of day; the
529
+ server's window also handles clock-change nights). Android's private
530
+ lock-screen channel setting hides content only when the phone's own "hide
531
+ sensitive content" is on, and a channel's settings are the person's once it
532
+ exists: the server's hidden previews are the guarantee, not the channel.
533
+
534
+ Rung 3: `createPermissionStore`, `createPromptStore`, `createRegistration`,
535
+ `createPreferencesStore`, `supabasePushBackend` (or your own `PushBackend`),
536
+ `pathForResponse`, the row builders (`permissionRows`, …). Rung 4: the hooks
537
+ under `PushProvider`, and `PushRoot` to mount the block without
538
+ GenesisNativeApp (inside a `SessionProvider`).
539
+
540
+ Not covered: delivery through Apple's and Google's push services hasn't been
541
+ tried (it needs an EAS project with APNs and FCM credentials), and there is no
542
+ in-app inbox: that is `inbox-thread` in the repo's `features.ts`.
543
+
544
+ ## Blocks
545
+
546
+ `blocks` takes `defineBlock` declarations (`@marktiderman/genesis-features/blocks`) a native shell
547
+ can run: a `NativeBlock` may also carry `bootSteps` (run behind the splash after your `steps`,
548
+ before the session) and a `Root` (mounted around your screens, inside the error boundary). The
549
+ updates block is one, so these are the same:
550
+
551
+ ```tsx
552
+ <GenesisNativeApp {...shell} updates={updates}> // as before
553
+ <GenesisNativeApp {...shell} blocks={[updates]}> // the same block, as a block
554
+ ```
555
+
556
+ The shell resolves the blocks once, at start (`resolveBlocks` with `platform: "native"`). Then:
557
+
558
+ - their boot steps run in dependency order, and their roots wrap your screens, the first block's outermost;
559
+ - their `contributes.slots` fill the shell's slots by name, under yours: your `slots` (a component
560
+ or `null`) always win;
561
+ - `useBlocks()` returns the resolved blocks (ids, nav entries, routes, migrations…), each
562
+ contribution tagged with its FeatureId;
563
+ - `useBlockSettings(sections, { navigate })` returns your Settings sections with every block's
564
+ rows added to the section they name (an unknown section is added at the end), live subtitles
565
+ included. The updates block adds the version signature to `about` (`versionRow` in
566
+ `createUpdates` sets its title and section, or `false` turns it off). Call it at the top of the
567
+ Settings screen and pass the result to `SettingsPage`.
568
+
569
+ A block may require your own features that aren't blocks: list them in `appFeatures`. Blocks that
570
+ don't resolve (a required block that wasn't passed or is off, an unknown slot name, two blocks
571
+ filling one slot) hide the splash, show the fatal screen and report a `BlockError` to `onError`:
572
+ a set-up mistake, found at launch.
573
+
233
574
  ## Each part on its own
234
575
 
235
576
  `createBoot` + `BootProvider` + `useBoot`; `GenesisErrorBoundary` +
@@ -262,7 +603,10 @@ splash (`expo-splash-screen`), storage (AsyncStorage or MMKV), `ErrorUtils`
262
603
  (the global by default), the rejection tracker (Hermes's by default),
263
604
  `AppState` and NetInfo are passed in, so it runs in tests with fakes. Peers:
264
605
  `react`, `react-native`, `@tanstack/react-query`, for `/persist` the two
265
- TanStack persister packages, and for `/updates` `react-native-safe-area-context`
266
- (with `expo-updates` and `expo-application` passed in).
606
+ TanStack persister packages, for `/updates` `react-native-safe-area-context`
607
+ (with `expo-updates` and `expo-application` passed in), and for `/push`
608
+ `expo-notifications`, passed in too.
609
+ `/observability` takes `@sentry/react-native` and `expo-observe` passed in;
610
+ `/devtools` takes expo-sensors' `Accelerometer` and React Native's `DevSettings` passed in.
267
611
 
268
612
  @stability Beta
@@ -0,0 +1,223 @@
1
+ import * as react from 'react';
2
+ import { ComponentType, ReactNode } from 'react';
3
+ import { N as NativeBlock, n as ErrorReport, x as NativeBlockRootProps } from './blocks-a1YQ1Elf.js';
4
+
5
+ /**
6
+ * Typed analytics events: the app lists every event and the props each may
7
+ * carry once, and `track(name, props)` accepts nothing else. A prop is a
8
+ * closed list of strings, a number or a boolean, never free text, so an
9
+ * event can't carry a name, an email or a message someone typed.
10
+ *
11
+ * ```ts
12
+ * export const events = defineEvents({
13
+ * "auth.signed_in": { method: ["email", "apple"] },
14
+ * "settings.opened": {},
15
+ * "checkin.saved": { mood: "number", first: "boolean" },
16
+ * });
17
+ * ```
18
+ *
19
+ * At run time an unknown event, an unknown prop or a value outside its list
20
+ * is dropped and reported (a `console.warn` in development, `onInvalid`
21
+ * always), so a bad call is loud and still sends nothing.
22
+ */
23
+ /** A closed list of strings, `"number"` (finite) or `"boolean"`. */
24
+ type EventPropSpec = readonly string[] | "number" | "boolean";
25
+ /** Every event, by name, with the props it may carry. */
26
+ type EventSchema = Readonly<Record<string, Readonly<Record<string, EventPropSpec>>>>;
27
+ type PropValue<S> = S extends readonly (infer L)[] ? L : S extends "number" ? number : S extends "boolean" ? boolean : never;
28
+ /** The props of one event: every prop optional, each its spec's type. */
29
+ type EventProps<S extends Readonly<Record<string, EventPropSpec>>> = {
30
+ readonly [K in keyof S]?: PropValue<S[K]>;
31
+ };
32
+ /** What a sink receives: the checked props. */
33
+ type EventValues = Readonly<Record<string, string | number | boolean>>;
34
+ /**
35
+ * Declare the app's events. Checks names (lower-case, dot-separated), prop
36
+ * names, and that each list has at least one short value, and throws listing
37
+ * every problem, so a bad schema fails where it is written.
38
+ *
39
+ * @stability Beta
40
+ */
41
+ declare function defineEvents<const E extends EventSchema>(schema: E): E;
42
+ type EventCheck = {
43
+ ok: true;
44
+ props: EventValues;
45
+ } | {
46
+ ok: false;
47
+ problem: string;
48
+ };
49
+ /** Check one call against the schema: the props to send, or why nothing is sent. */
50
+ declare function checkEvent(schema: EventSchema, name: string, props: Readonly<Record<string, unknown>> | undefined): EventCheck;
51
+
52
+ /**
53
+ * The vendor-neutral seam: a sink is where errors, breadcrumbs, the user id,
54
+ * analytics events and timings go. `createObservability` fans every call out
55
+ * to its sinks, scrubbed first; a sink that throws is reported to
56
+ * `onSinkError` and never takes the others (or the app) down with it.
57
+ *
58
+ * Shipped sinks: `sentrySink` (Sentry), `observeSink` (EAS Observe) and
59
+ * `consoleSink` (development). A vendor adapter (PostHog, Datadog…) is one
60
+ * object with the methods it supports.
61
+ */
62
+
63
+ /** Where a caught error came from and whether the app could go on. */
64
+ interface ErrorContext {
65
+ /** True when the app could not go on (a render error, a failed boot, a fatal global error). */
66
+ fatal: boolean;
67
+ /** `boundary`, `global`, `boot`, `rejection` (the shell's), or the app's own (`updates:policy`, `sync`). */
68
+ source: string;
69
+ componentStack?: string;
70
+ /** The route pattern on screen (`/(app)/settings`), when known. */
71
+ route?: string | null;
72
+ }
73
+ type BreadcrumbLevel = "debug" | "info" | "warning" | "error";
74
+ /** One step that led up to an error: a navigation, a tap, a request. */
75
+ interface Breadcrumb {
76
+ /** `navigation`, `ui`, `http`, `event`, or the app's own. */
77
+ category: string;
78
+ message?: string;
79
+ level?: BreadcrumbLevel;
80
+ data?: Readonly<Record<string, unknown>>;
81
+ /** Milliseconds since the epoch. Default now. */
82
+ timestamp?: number;
83
+ }
84
+ /** Low-cardinality labels on a timing (`route`, `step`), never ids or text someone typed. */
85
+ type TimingTags = Readonly<Record<string, string>>;
86
+ /**
87
+ * One destination. Every method is optional: a sink takes what its vendor
88
+ * supports. Errors arrive as an `Error` whose message is already scrubbed,
89
+ * unless the sink sets `scrubsErrors` (it scrubs the whole event itself and
90
+ * needs the original object, as Sentry does to deduplicate).
91
+ *
92
+ * @stability Beta
93
+ */
94
+ interface ObservabilitySink {
95
+ readonly name: string;
96
+ /** The sink scrubs error events itself; give it the original `Error`. */
97
+ readonly scrubsErrors?: boolean;
98
+ captureError?(error: Error, context: ErrorContext): void;
99
+ breadcrumb?(crumb: Breadcrumb): void;
100
+ /** The signed-in login's id, or `null` when signed out. Never an email or a name. */
101
+ setUser?(id: string | null): void;
102
+ /** An analytics event, its props checked against the schema. */
103
+ event?(name: string, props: EventValues): void;
104
+ timing?(name: string, ms: number, tags?: TimingTags): void;
105
+ /** Wraps the root layout (Sentry's `wrap`, EAS Observe's `ObserveRoot.wrap`). */
106
+ wrap?<P extends Record<string, unknown>>(root: ComponentType<P>): ComponentType<P>;
107
+ }
108
+ /** A copy of `error` whose message and stack have been scrubbed (for sinks that don't scrub themselves). */
109
+ declare function scrubbedError(error: Error): Error;
110
+ interface ConsoleSinkOptions {
111
+ /** Default `console`. */
112
+ console?: Pick<Console, "log" | "warn" | "error">;
113
+ /** Also log breadcrumbs (noisy). Default false. */
114
+ breadcrumbs?: boolean;
115
+ }
116
+ /**
117
+ * Logs what would be sent, for development: fatal errors with `console.error`, others with `console.warn`,
118
+ * events and timings with `console.log`. Already scrubbed, so what you read
119
+ * is what a vendor sink would get.
120
+ *
121
+ * @stability Beta
122
+ */
123
+ declare function consoleSink(options?: ConsoleSinkOptions): ObservabilitySink;
124
+ /**
125
+ * `expo-observe`'s `Observe` (SDK 56+), or a stand-in: the calls `observeSink`
126
+ * uses. Pass `import { Observe } from "expo-observe"`.
127
+ */
128
+ interface ObserveLike {
129
+ logEvent(name: string, options?: {
130
+ severity?: "debug" | "info" | "warn" | "error";
131
+ body?: string;
132
+ attributes?: Record<string, unknown>;
133
+ }): void;
134
+ /** Error reporting, in preview on SDK 57+. */
135
+ reportError?(error: unknown): void;
136
+ }
137
+ interface ObserveSinkOptions {
138
+ /** Report caught errors with `Observe.reportError` (in preview). Default false: Sentry is the error tracker. */
139
+ errors?: boolean;
140
+ }
141
+ /**
142
+ * EAS Observe. It measures launch (cold, warm, bundle load, first render),
143
+ * time to interactive and, with its Expo Router integration, per-route
144
+ * render times on its own; it has no API for a custom timing metric, so
145
+ * `timing` is logged as an event (`timing.<name>` with `duration_ms`) and
146
+ * shows on the Events page, not as a metric. Analytics events go to
147
+ * `Observe.logEvent` as they are.
148
+ *
149
+ * @stability Beta
150
+ */
151
+ declare function observeSink(observe: ObserveLike, options?: ObserveSinkOptions): ObservabilitySink;
152
+ /** Breadcrumb data scrubbed and made plain (for sinks and the devtools overlay). */
153
+ declare function scrubBreadcrumb(crumb: Breadcrumb): Breadcrumb;
154
+
155
+ /** The FeatureId of the observability block. */
156
+ declare const OBSERVABILITY_BLOCK_ID = "observability";
157
+ /** A caught error as the devtools overlay shows it (scrubbed). */
158
+ interface RecentError {
159
+ at: number;
160
+ name: string;
161
+ message: string;
162
+ source: string;
163
+ fatal: boolean;
164
+ }
165
+ interface RecentActivity {
166
+ /** Oldest first. Navigation, events and the app's breadcrumbs. */
167
+ breadcrumbs: readonly Breadcrumb[];
168
+ /** Oldest first. */
169
+ errors: readonly RecentError[];
170
+ }
171
+ interface RecentStore {
172
+ getState(): RecentActivity;
173
+ subscribe(listener: () => void): () => void;
174
+ clear(): void;
175
+ }
176
+ interface ObservabilityOptions<E extends EventSchema = EventSchema> {
177
+ /** Where everything goes: `sentrySink(Sentry, …)`, `consoleSink()` in development, `observeSink(Observe)`. */
178
+ sinks: readonly (ObservabilitySink | null | undefined | false)[];
179
+ /** The app's analytics events (`defineEvents`). Without it, `track` accepts nothing. */
180
+ events?: E;
181
+ /** `useSegments` from expo-router: route breadcrumbs and `route.time_on_screen` timings. */
182
+ useSegments?: () => readonly string[];
183
+ /** How many breadcrumbs and errors `recent` keeps. Default 50 and 20. */
184
+ recent?: {
185
+ breadcrumbs?: number;
186
+ errors?: number;
187
+ };
188
+ /** A sink threw, or `track` was called with something the schema doesn't allow. Default `console.warn` in development. */
189
+ onProblem?: (problem: string, error?: unknown) => void;
190
+ now?: () => number;
191
+ }
192
+ interface ObservabilityBlock<E extends EventSchema = EventSchema> extends NativeBlock<typeof OBSERVABILITY_BLOCK_ID> {
193
+ readonly sinks: readonly ObservabilitySink[];
194
+ readonly events: E | undefined;
195
+ /** Report an error. The shell calls it for every error it catches. */
196
+ captureError(error: unknown, context?: Partial<ErrorContext>): void;
197
+ breadcrumb(crumb: Breadcrumb): void;
198
+ /** The login's id only. The block's root sets it from the session. */
199
+ setUser(id: string | null): void;
200
+ /** An analytics event from the schema. Anything else is dropped and reported. */
201
+ track<N extends keyof E & string>(name: N, props?: EventProps<E[N]>): void;
202
+ timing(name: string, ms: number, tags?: TimingTags): void;
203
+ /** The root layout wrapped by each sink that wraps (Sentry's `wrap`). Use once: `export default observability.wrap(RootLayout)`. */
204
+ wrap<P extends Record<string, unknown>>(root: ComponentType<P>): ComponentType<P>;
205
+ /** The last breadcrumbs and errors, scrubbed, for the devtools overlay. */
206
+ readonly recent: RecentStore;
207
+ /** For the shell: every error it catches. */
208
+ readonly onError: (error: Error, report: ErrorReport) => void;
209
+ readonly Root: ComponentType<NativeBlockRootProps>;
210
+ }
211
+ /** Build the block once, at module scope, before the root layout renders. @stability Beta */
212
+ declare function createObservability<const E extends EventSchema = EventSchema>(options: ObservabilityOptions<E>): ObservabilityBlock<E>;
213
+ /** Provides a block to the hooks without its root (rung 4). @stability Beta */
214
+ declare function ObservabilityProvider({ block, children }: {
215
+ block: ObservabilityBlock;
216
+ children?: ReactNode;
217
+ }): react.JSX.Element;
218
+ /** The block, or `null` when it is off (so a screen can call it whether or not the block is on). @stability Beta */
219
+ declare function useObservability(): ObservabilityBlock | null;
220
+ /** The last breadcrumbs and errors, scrubbed; empty when the block is off. @stability Beta */
221
+ declare function useRecentActivity(block?: ObservabilityBlock | null): RecentActivity;
222
+
223
+ export { type Breadcrumb as B, type ConsoleSinkOptions as C, type ErrorContext as E, type ObservabilityBlock as O, type RecentActivity as R, type TimingTags as T, type ObservabilitySink as a, type BreadcrumbLevel as b, type EventCheck as c, type EventPropSpec as d, type EventProps as e, type EventSchema as f, type EventValues as g, OBSERVABILITY_BLOCK_ID as h, type ObservabilityOptions as i, ObservabilityProvider as j, type ObserveLike as k, type ObserveSinkOptions as l, type RecentError as m, type RecentStore as n, checkEvent as o, consoleSink as p, createObservability as q, defineEvents as r, observeSink as s, scrubBreadcrumb as t, scrubbedError as u, useObservability as v, useRecentActivity as w };