@marktiderman/genesis-native 0.2.0 → 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 +470 -10
- package/dist/block-Onqx7NiK.d.ts +223 -0
- package/dist/blocks-C2RDpvaN.d.ts +1595 -0
- package/dist/chunk-65P7RU4V.js +49 -0
- package/dist/chunk-65P7RU4V.js.map +1 -0
- package/dist/chunk-6BT4PTGH.js +37 -0
- package/dist/chunk-6BT4PTGH.js.map +1 -0
- package/dist/chunk-75YP72G6.js +374 -0
- package/dist/chunk-75YP72G6.js.map +1 -0
- package/dist/chunk-NGYNYJPL.js +90 -0
- package/dist/chunk-NGYNYJPL.js.map +1 -0
- package/dist/chunk-ONAXQHNG.js +23 -0
- package/dist/chunk-ONAXQHNG.js.map +1 -0
- package/dist/chunk-VKTIHNAV.js +95 -0
- package/dist/chunk-VKTIHNAV.js.map +1 -0
- package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
- package/dist/chunk-VTJDIKFC.js.map +1 -0
- package/dist/chunk-XE7LC7K6.js +29 -0
- package/dist/chunk-XE7LC7K6.js.map +1 -0
- package/dist/dev-warnings.d.ts +34 -0
- package/dist/dev-warnings.js +15 -0
- package/dist/dev-warnings.js.map +1 -0
- package/dist/devtools.d.ts +144 -0
- package/dist/devtools.js +390 -0
- package/dist/devtools.js.map +1 -0
- package/dist/index.d.ts +12 -368
- package/dist/index.js +88 -191
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +170 -0
- package/dist/observability.js +123 -0
- package/dist/observability.js.map +1 -0
- package/dist/persist.d.ts +1 -1
- package/dist/persist.js +1 -1
- package/dist/push-payload.d.ts +43 -0
- package/dist/push-payload.js +3 -0
- package/dist/push-payload.js.map +1 -0
- package/dist/push.d.ts +187 -0
- package/dist/push.js +1069 -0
- package/dist/push.js.map +1 -0
- package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
- package/dist/rich-push-plugin.d.ts +74 -0
- package/dist/rich-push-plugin.js +209 -0
- package/dist/rich-push-plugin.js.map +1 -0
- package/dist/updates.d.ts +8 -1
- package/dist/updates.js +44 -69
- package/dist/updates.js.map +1 -1
- package/ios/notification-service/NotificationService.swift +309 -0
- package/package.json +238 -5
- package/src/app.tsx +491 -0
- package/src/auth.tsx +176 -0
- package/src/blocks.tsx +144 -0
- package/src/boot.tsx +188 -0
- package/src/dev-warnings.ts +43 -0
- package/src/devtools/block.tsx +488 -0
- package/src/devtools/gestures.ts +107 -0
- package/src/devtools/index.ts +22 -0
- package/src/devtools/info.ts +45 -0
- package/src/devtools/slots.ts +21 -0
- package/src/errors.tsx +195 -0
- package/src/held-link.ts +90 -0
- package/src/hydration-gate.ts +88 -0
- package/src/index.ts +83 -0
- package/src/links.ts +140 -0
- package/src/nativewind.d.ts +41 -0
- package/src/observability/block.tsx +300 -0
- package/src/observability/events.ts +89 -0
- package/src/observability/index.ts +44 -0
- package/src/observability/scrub.ts +166 -0
- package/src/observability/sentry.ts +222 -0
- package/src/observability/sink.ts +147 -0
- package/src/persist.ts +83 -0
- package/src/plugin/rich-push.ts +312 -0
- package/src/push/backend.ts +137 -0
- package/src/push/block.tsx +604 -0
- package/src/push/index.ts +105 -0
- package/src/push/modules.ts +123 -0
- package/src/push/payload.ts +61 -0
- package/src/push/permission.ts +191 -0
- package/src/push/preferences.ts +136 -0
- package/src/push/quiet-hours.ts +71 -0
- package/src/push/registration.ts +225 -0
- package/src/push/routing.ts +57 -0
- package/src/push/screens.tsx +46 -0
- package/src/push/settings.tsx +378 -0
- package/src/query.ts +100 -0
- package/src/updates/block.tsx +442 -0
- package/src/updates/channels.ts +131 -0
- package/src/updates/checker.ts +190 -0
- package/src/updates/index.ts +87 -0
- package/src/updates/modules.ts +64 -0
- package/src/updates/policy.ts +314 -0
- package/src/updates/screens.tsx +159 -0
- package/src/updates/signature.ts +80 -0
- package/src/updates/store.ts +33 -0
- package/src/updates/version.ts +39 -0
- package/src/updates/whats-new.ts +122 -0
- package/dist/block-Dh5ny06W.d.ts +0 -535
- 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**:
|
|
40
|
-
|
|
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`
|
|
59
|
-
|
|
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: "
|
|
67
|
-
|
|
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,419 @@ 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
|
+
or on [`@marktiderman/genesis-notifications`](../notifications/README.md), which
|
|
435
|
+
now owns the server side of push (it adopts 0005's tables and sends through
|
|
436
|
+
one runner). With the notifier, pass its backend and the registry's categories:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import * as Crypto from "expo-crypto";
|
|
440
|
+
import { notificationsPushBackend, pushOptionsFrom } from "@marktiderman/genesis-notifications/native";
|
|
441
|
+
|
|
442
|
+
createPush({
|
|
443
|
+
…,
|
|
444
|
+
backend: notificationsPushBackend(supabase), // registers with this install's id
|
|
445
|
+
...pushOptionsFrom(notifications), // categories (Android channels) and device categories (action buttons)
|
|
446
|
+
randomUUID: Crypto.randomUUID, // the install id; Hermes has no crypto.randomUUID
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
`deviceCategories` registers notification categories with their buttons (Reply
|
|
451
|
+
with a text field, Mark as read) when the block starts; a button tap reaches
|
|
452
|
+
`onResponse`, and `notificationAction(response)` reads it: the action id, the
|
|
453
|
+
typed text, the link and the notifier's notification id. A backend that sets
|
|
454
|
+
`usesInstallationId` gets this install's id (made once, kept across sign-ins)
|
|
455
|
+
as `register`'s fourth argument; `supabasePushBackend` doesn't.
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
// lib/push.ts
|
|
459
|
+
import * as Notifications from "expo-notifications";
|
|
460
|
+
import * as Application from "expo-application";
|
|
461
|
+
import Constants from "expo-constants";
|
|
462
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
463
|
+
import { router } from "expo-router";
|
|
464
|
+
import { AppState, Linking, Platform } from "react-native";
|
|
465
|
+
import { createPush, supabasePushBackend } from "@marktiderman/genesis-native/push";
|
|
466
|
+
|
|
467
|
+
export const push = createPush({
|
|
468
|
+
notifications: Notifications,
|
|
469
|
+
platform: Platform.OS,
|
|
470
|
+
projectId: Constants.expoConfig?.extra?.eas?.projectId,
|
|
471
|
+
appVersion: Application.nativeApplicationVersion,
|
|
472
|
+
backend: supabasePushBackend(supabase),
|
|
473
|
+
categories: [{ id: "messages", title: "Messages" }, { id: "digest", title: "Weekly digest", defaultOn: false }],
|
|
474
|
+
storage: AsyncStorage,
|
|
475
|
+
appState: AppState,
|
|
476
|
+
openSettings: Linking.openSettings,
|
|
477
|
+
links, // the same LinkOptions as +native-intent
|
|
478
|
+
hold: linkHold, // the same hold as GenesisNativeApp's links
|
|
479
|
+
onOpen: (path) => router.push(path),
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// app/_layout.tsx
|
|
483
|
+
<GenesisNativeApp blocks={[push]} links={{ hold: linkHold, onRelease: router.replace }} … />
|
|
484
|
+
|
|
485
|
+
// app/settings/notifications.tsx
|
|
486
|
+
<NotificationSettings />
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
- **Permission, asked well.** The block never asks at launch. Call
|
|
490
|
+
`usePushPrompt().offer("when someone replies")` at the moment push is worth
|
|
491
|
+
it; the `pushPrompt` slot (default `PushPromptSheet`) says why first, and only
|
|
492
|
+
its "Turn on" brings up the system prompt. `offer` is ignored for
|
|
493
|
+
`prompt.launchGraceMs` (10 s) after start (so offer on a screen's focus, not
|
|
494
|
+
only its mount), while signed out, once the system
|
|
495
|
+
can't ask again, for `cooldownMs` (3 days) after "Not now", and for good
|
|
496
|
+
after `maxDeclines` (3). A tap on "Allow notifications" in
|
|
497
|
+
`NotificationSettings` asks straight away (the tap is the intent); once the
|
|
498
|
+
system won't ask, it opens Settings (`openSettings`). iOS provisional
|
|
499
|
+
permission counts as granted.
|
|
500
|
+
- **The token follows the session.** Once permission is granted, a signed-in
|
|
501
|
+
person's Expo push token (`getExpoPushTokenAsync({ projectId })`) is
|
|
502
|
+
registered with `genesis_push_register`, again when the OS rotates it
|
|
503
|
+
(`addPushTokenListener`) and at most hourly otherwise. Sign-out, an account
|
|
504
|
+
switch and deleting the account forget it (`genesis_push_forget`, which works
|
|
505
|
+
without a session: holding the token is the proof); a forget that fails
|
|
506
|
+
offline is kept on the phone and retried each time the app comes to the
|
|
507
|
+
front, and never undoes a later registration of the same token. Coming to the
|
|
508
|
+
front also registers again when the last try failed, and at most hourly
|
|
509
|
+
otherwise (so a token another login took comes back). Without a `projectId` there is no
|
|
510
|
+
token: the block says so once through `onError`, and Settings says so on
|
|
511
|
+
screen.
|
|
512
|
+
- **Android channels.** A `default` channel, one per category (named after it,
|
|
513
|
+
so a person can mute a category in Android's own settings) and any
|
|
514
|
+
`channels` you add, all with private lock-screen visibility, made before the
|
|
515
|
+
permission prompt and the token (Android 13 shows its prompt only once a
|
|
516
|
+
channel exists). The sender uses the category as the channel id.
|
|
517
|
+
- **While the app is open** the foreground handler shows a notification
|
|
518
|
+
(banner, list, sound, badge) unless `foreground(notification)` says no or a
|
|
519
|
+
screen hides its kind: `useSuppressNotifications((data) => data.threadId === id)`.
|
|
520
|
+
Signed out, nothing shows.
|
|
521
|
+
- **Taps.** `data.url` of a tapped notification (and of the one that launched
|
|
522
|
+
the app, `getLastNotificationResponse`) goes through `normalizeLink` with
|
|
523
|
+
`links`, once per notification: an unknown path opens `/`, another app's URL
|
|
524
|
+
never opens. Signed in, `onOpen(path)` runs after the navigator mounts;
|
|
525
|
+
signed out, the path is held in `hold` (the same `linkHold` as
|
|
526
|
+
GenesisNativeApp's `links.hold`) and followed after sign-in. Without `onOpen`
|
|
527
|
+
or `hold`, a tap opens nothing and says so through `onError`. Action buttons
|
|
528
|
+
go to `onResponse`.
|
|
529
|
+
- **Badge.** `useBadge().set(n)` / `clear()`; the sender can set it too. It is
|
|
530
|
+
cleared on sign-out (`clearBadgeOnForeground` clears it on every return to
|
|
531
|
+
the front).
|
|
532
|
+
- **NotificationSettings** is a `SettingsPage` with four parts, each a slot and
|
|
533
|
+
a hook: `permission` (`usePushPermission`), `categories`
|
|
534
|
+
(`useNotificationCategories`, a switch per category), `quietHours`
|
|
535
|
+
(`useQuietHours`: a switch, from and to times with `NativeTimePicker`, and
|
|
536
|
+
the time zone; turning it on fills in the phone's zone, and a phone that has
|
|
537
|
+
travelled offers its new one) and `privacy` (`usePrivacyPreviews`: "Show
|
|
538
|
+
previews"). `slots={{ quietHours: Mine }}` renders yours in the same section
|
|
539
|
+
with the same props; `null` drops the section. Nothing can be changed until
|
|
540
|
+
the saved settings have loaded (a change on top of the defaults would save
|
|
541
|
+
them over the person's choices); a failed load says so and is tried again
|
|
542
|
+
when the screen opens. Changes save at once and are put back, with a note,
|
|
543
|
+
when the save fails. The words are
|
|
544
|
+
`notificationSettingsCopy`, replaced with `copy`. The time pickers need
|
|
545
|
+
`@react-native-community/datetimepicker`; without it the times are shown, not
|
|
546
|
+
editable.
|
|
547
|
+
- **What the server decides.** Quiet hours and hidden previews are applied by
|
|
548
|
+
the sender (`genesis_push_targets`), in the person's saved time zone, so a
|
|
549
|
+
phone with a wrong clock can't change them, and hidden previews never leave
|
|
550
|
+
the server. The phone only shows them (`isQuietAt`, by time of day; the
|
|
551
|
+
server's window also handles clock-change nights). Android's private
|
|
552
|
+
lock-screen channel setting hides content only when the phone's own "hide
|
|
553
|
+
sensitive content" is on, and a channel's settings are the person's once it
|
|
554
|
+
exists: the server's hidden previews are the guarantee, not the channel.
|
|
555
|
+
|
|
556
|
+
Rung 3: `createPermissionStore`, `createPromptStore`, `createRegistration`,
|
|
557
|
+
`createPreferencesStore`, `supabasePushBackend` (or your own `PushBackend`),
|
|
558
|
+
`pathForResponse`, the row builders (`permissionRows`, …). Rung 4: the hooks
|
|
559
|
+
under `PushProvider`, and `PushRoot` to mount the block without
|
|
560
|
+
GenesisNativeApp (inside a `SessionProvider`).
|
|
561
|
+
|
|
562
|
+
Not covered: delivery through Apple's and Google's push services hasn't been
|
|
563
|
+
tried (it needs an EAS project with APNs and FCM credentials), and there is no
|
|
564
|
+
in-app inbox: that is `inbox-thread` in the repo's `features.ts`.
|
|
565
|
+
|
|
566
|
+
### iOS rich push: photos, the sender's avatar, Communication Notifications
|
|
567
|
+
|
|
568
|
+
`@marktiderman/genesis-native/rich-push-plugin` is an Expo config plugin that
|
|
569
|
+
adds a Notification Service Extension to the iOS app. iOS runs the extension
|
|
570
|
+
on each push that says `mutable-content: 1`, before it is shown, for up to
|
|
571
|
+
about 30 seconds. The extension:
|
|
572
|
+
|
|
573
|
+
- **attaches pictures.** It downloads `data.image` and `data.senderAvatar`
|
|
574
|
+
when they are https URLs (no credentials, at most 1024 characters; JPEG, PNG
|
|
575
|
+
or GIF; at most 10 MB) and attaches them. Only the avatar is read into memory
|
|
576
|
+
(at most 1 MB; a bigger one is still attached, but not drawn as the sender),
|
|
577
|
+
because the extension gets about 24 MB. A download that fails or is
|
|
578
|
+
late is skipped. The notification is always shown, with what the payload
|
|
579
|
+
already said, and is never dropped.
|
|
580
|
+
- **makes chat pushes Communication Notifications.** A push in the `messages`
|
|
581
|
+
category with a `senderName` becomes an incoming `INSendMessageIntent`. It
|
|
582
|
+
carries the sender (`senderName`, `senderAvatar`, and `senderId` as the
|
|
583
|
+
handle), the conversation (`threadId`, else `data.conversationId`) and, in a
|
|
584
|
+
group, `groupName` (iOS draws its generic group picture). The extension then
|
|
585
|
+
calls `content.updating(from:)`. The
|
|
586
|
+
sender's photo shows on the notification, and Focus treats it as a message
|
|
587
|
+
(a person can let a Focus through). On a `messages` push the avatar is the
|
|
588
|
+
sender's photo; on any other push it is attached.
|
|
589
|
+
- **downloads only what you allow.** With `imageHosts`, images come only from
|
|
590
|
+
those hosts and their subdomains. Without it, any https host is used. Set it
|
|
591
|
+
when a URL in a push can be one a person chose (an avatar URL they typed),
|
|
592
|
+
because that host would otherwise see each delivery.
|
|
593
|
+
- **never fetches content.** It reads only the payload. With hidden previews
|
|
594
|
+
(`hidePreviews`, applied by the sender), the server sends neutral text and no
|
|
595
|
+
sender, group or picture fields, so there is nothing to show. If the
|
|
596
|
+
communication update is refused (for example, no entitlement), the plain
|
|
597
|
+
notification shows instead.
|
|
598
|
+
|
|
599
|
+
**Setup.** Add the plugin after any plugin that changes the bundle id (the
|
|
600
|
+
Genesis plugin's `APP_VARIANT` suffix), then prebuild:
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
plugins: [
|
|
604
|
+
"expo-notifications",
|
|
605
|
+
"@marktiderman/genesis-cli",
|
|
606
|
+
["@marktiderman/genesis-native/rich-push-plugin", { imageHosts: ["yourproject.supabase.co"] }], // also: targetName, bundleIdSuffix, communication, deploymentTarget
|
|
607
|
+
],
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
It runs on the app's own `expo` (`expo/config-plugins`), so it needs nothing
|
|
611
|
+
else installed.
|
|
612
|
+
|
|
613
|
+
What it sets:
|
|
614
|
+
|
|
615
|
+
- **App:** the `com.apple.developer.usernotifications.communication`
|
|
616
|
+
entitlement and `NSUserActivityTypes: ["INSendMessageIntent"]` in
|
|
617
|
+
Info.plist. Both are skipped with `communication: false`, which keeps
|
|
618
|
+
attachments only.
|
|
619
|
+
- **Extension:** a target named `GenesisNotificationService` in
|
|
620
|
+
`ios/GenesisNotificationService/` (the Swift from this package's
|
|
621
|
+
`ios/notification-service/`, an Info.plist declaring `INSendMessageIntent`
|
|
622
|
+
and `GenesisRichPushImageHosts`, and entitlements). Its bundle id
|
|
623
|
+
is `<app id>.NotificationService`. It uses the app's version, build number,
|
|
624
|
+
minimum iOS and team. The app embeds it and depends on it.
|
|
625
|
+
- **EAS:** `extra.eas.build.experimental.ios.appExtensions`. The extension is
|
|
626
|
+
a separate bundle id, so it needs its own provisioning profile. EAS Build
|
|
627
|
+
reads this entry and creates the App ID, its capability and the profile with
|
|
628
|
+
the app's. You don't create them by hand. A local `expo run:ios` on a device
|
|
629
|
+
needs an Apple team (`ios.appleTeamId`) that can sign both.
|
|
630
|
+
|
|
631
|
+
**The payload contract.** The sender sets `mutableContent: true`, or the
|
|
632
|
+
extension never runs. `richPushMessage(message, { image, senderAvatar })`
|
|
633
|
+
(also exported from `/push-payload`, which imports no React, for servers and
|
|
634
|
+
edge functions) sets it and drops image URLs that aren't https. For chat,
|
|
635
|
+
`chatPushPayload` from `@marktiderman/genesis-chat/push` builds the whole set:
|
|
636
|
+
|
|
637
|
+
| Field | Where | Read by |
|
|
638
|
+
| --- | --- | --- |
|
|
639
|
+
| `mutableContent: true` | message (`mutable-content: 1`) | iOS: runs the extension |
|
|
640
|
+
| `categoryId: "messages"` | message (`aps.category`) | the extension (Communication Notification), Reply and Mark read |
|
|
641
|
+
| `threadId` | message (`aps.thread-id`), the conversation id | grouping, and the intent's conversation |
|
|
642
|
+
| `data.image` | https | attached |
|
|
643
|
+
| `data.senderAvatar` | https | the sender's photo (`messages`), else attached |
|
|
644
|
+
| `data.senderName`, `data.senderId` | text, an opaque id | the intent's sender; without a name the push stays plain |
|
|
645
|
+
| `data.groupName` | text, groups only | the intent's group name |
|
|
646
|
+
| `data.conversationId`, `data.url`, `data.seq`, `data.messageId` | ids and the link | tap routing, Reply, Mark read |
|
|
647
|
+
|
|
648
|
+
APNs takes 4 KB per push, so keep the URLs short (a storage path, not a long
|
|
649
|
+
signed URL). Expo's push service delivers `data` under the APNs payload's
|
|
650
|
+
`body` key. The
|
|
651
|
+
extension reads it there, or from the payload's top level for a push sent to
|
|
652
|
+
APNs directly.
|
|
653
|
+
|
|
654
|
+
Not covered: the extension has been built and run on the iOS Simulator only.
|
|
655
|
+
It hasn't been run on a device or from an EAS build. Whether EAS's build number
|
|
656
|
+
increment reaches the extension (its `CURRENT_PROJECT_VERSION` is set at
|
|
657
|
+
prebuild) hasn't been checked either. Donated conversations stay in Siri's
|
|
658
|
+
suggestions after a person turns previews off.
|
|
659
|
+
|
|
660
|
+
## Blocks
|
|
661
|
+
|
|
662
|
+
`blocks` takes `defineBlock` declarations (`@marktiderman/genesis-features/blocks`) a native shell
|
|
663
|
+
can run: a `NativeBlock` may also carry `bootSteps` (run behind the splash after your `steps`,
|
|
664
|
+
before the session) and a `Root` (mounted around your screens, inside the error boundary). The
|
|
665
|
+
updates block is one, so these are the same:
|
|
666
|
+
|
|
667
|
+
```tsx
|
|
668
|
+
<GenesisNativeApp {...shell} updates={updates}> // as before
|
|
669
|
+
<GenesisNativeApp {...shell} blocks={[updates]}> // the same block, as a block
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
The shell resolves the blocks once, at start (`resolveBlocks` with `platform: "native"`). Then:
|
|
673
|
+
|
|
674
|
+
- their boot steps run in dependency order, and their roots wrap your screens, the first block's outermost;
|
|
675
|
+
- their `contributes.slots` fill the shell's slots by name, under yours: your `slots` (a component
|
|
676
|
+
or `null`) always win;
|
|
677
|
+
- `useBlocks()` returns the resolved blocks (ids, nav entries, routes, migrations…), each
|
|
678
|
+
contribution tagged with its FeatureId;
|
|
679
|
+
- `useBlockSettings(sections, { navigate })` returns your Settings sections with every block's
|
|
680
|
+
rows added to the section they name (an unknown section is added at the end), live subtitles
|
|
681
|
+
included. The updates block adds the version signature to `about` (`versionRow` in
|
|
682
|
+
`createUpdates` sets its title and section, or `false` turns it off). Call it at the top of the
|
|
683
|
+
Settings screen and pass the result to `SettingsPage`.
|
|
684
|
+
|
|
685
|
+
A block may require your own features that aren't blocks: list them in `appFeatures`. Blocks that
|
|
686
|
+
don't resolve (a required block that wasn't passed or is off, an unknown slot name, two blocks
|
|
687
|
+
filling one slot) hide the splash, show the fatal screen and report a `BlockError` to `onError`:
|
|
688
|
+
a set-up mistake, found at launch.
|
|
689
|
+
|
|
233
690
|
## Each part on its own
|
|
234
691
|
|
|
235
692
|
`createBoot` + `BootProvider` + `useBoot`; `GenesisErrorBoundary` +
|
|
@@ -262,7 +719,10 @@ splash (`expo-splash-screen`), storage (AsyncStorage or MMKV), `ErrorUtils`
|
|
|
262
719
|
(the global by default), the rejection tracker (Hermes's by default),
|
|
263
720
|
`AppState` and NetInfo are passed in, so it runs in tests with fakes. Peers:
|
|
264
721
|
`react`, `react-native`, `@tanstack/react-query`, for `/persist` the two
|
|
265
|
-
TanStack persister packages,
|
|
266
|
-
(with `expo-updates` and `expo-application` passed in)
|
|
722
|
+
TanStack persister packages, for `/updates` `react-native-safe-area-context`
|
|
723
|
+
(with `expo-updates` and `expo-application` passed in), and for `/push`
|
|
724
|
+
`expo-notifications`, passed in too.
|
|
725
|
+
`/observability` takes `@sentry/react-native` and `expo-observe` passed in;
|
|
726
|
+
`/devtools` takes expo-sensors' `Accelerometer` and React Native's `DevSettings` passed in.
|
|
267
727
|
|
|
268
728
|
@stability Beta
|