@marktiderman/genesis-native 0.3.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 CHANGED
@@ -430,7 +430,29 @@ permission as its subtitle; `settingsRow` changes or drops it) and a boot step
430
430
  that reads the permission behind the splash (a second at most; it never fails
431
431
  the boot), and names its migration. It runs on migration
432
432
  0005 and the `genesis-push-send` function of
433
- [`@marktiderman/genesis-supabase`](../supabase/README.md#push-the-push-block).
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.
434
456
 
435
457
  ```ts
436
458
  // lib/push.ts
@@ -541,6 +563,100 @@ Not covered: delivery through Apple's and Google's push services hasn't been
541
563
  tried (it needs an EAS project with APNs and FCM credentials), and there is no
542
564
  in-app inbox: that is `inbox-thread` in the repo's `features.ts`.
543
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
+
544
660
  ## Blocks
545
661
 
546
662
  `blocks` takes `defineBlock` declarations (`@marktiderman/genesis-features/blocks`) a native shell
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentType, ReactNode } from 'react';
3
- import { N as NativeBlock, n as ErrorReport, x as NativeBlockRootProps } from './blocks-a1YQ1Elf.js';
3
+ import { N as NativeBlock, n as ErrorReport, x as NativeBlockRootProps } from './blocks-C2RDpvaN.js';
4
4
 
5
5
  /**
6
6
  * Typed analytics events: the app lists every event and the props each may
@@ -878,8 +878,16 @@ interface QuietHours {
878
878
  }
879
879
  declare const DEFAULT_PREFERENCES: NotificationPreferences;
880
880
  interface PushBackend {
881
- /** Keep this device's token against the signed-in person. */
882
- register(token: string, platform: string, appVersion: string | null): Promise<void>;
881
+ /**
882
+ * Keep this device's token against the signed-in person. `installationId`
883
+ * is this install's random id (kept on the phone across sign-ins): a
884
+ * backend that checks it moves a token another login holds only when the
885
+ * id matches (the same phone). `supabasePushBackend` (migration 0005)
886
+ * doesn't send it; genesis-notifications' backend does.
887
+ */
888
+ register(token: string, platform: string, appVersion: string | null, installationId?: string): Promise<void>;
889
+ /** Send `installationId` to `register` (the block makes and keeps one). Default false. */
890
+ readonly usesInstallationId?: boolean;
883
891
  /** Forget this device's token; works signed out (holding the token is the proof). */
884
892
  forget(token: string): Promise<void>;
885
893
  /** The signed-in person's preferences, or `null` before they have saved any. */
@@ -951,6 +959,22 @@ interface NotificationLike {
951
959
  interface NotificationResponseLike {
952
960
  notification: NotificationLike;
953
961
  actionIdentifier: string;
962
+ /** What the person typed, for an action with a text input (Reply). */
963
+ userText?: string;
964
+ }
965
+ /** `NotificationAction` (expo-notifications 58): one button of a notification category. */
966
+ interface NotificationActionLike {
967
+ identifier: string;
968
+ buttonTitle: string;
969
+ textInput?: {
970
+ placeholder: string;
971
+ submitButtonTitle: string;
972
+ };
973
+ options?: {
974
+ isAuthenticationRequired?: boolean;
975
+ isDestructive?: boolean;
976
+ opensAppToForeground?: boolean;
977
+ };
954
978
  }
955
979
  /** `NotificationBehavior` without the deprecated `shouldShowAlert`. */
956
980
  interface NotificationBehaviorLike {
@@ -990,6 +1014,10 @@ interface NotificationsModuleLike {
990
1014
  getLastNotificationResponse(): NotificationResponseLike | null;
991
1015
  clearLastNotificationResponse(): void;
992
1016
  setBadgeCountAsync(count: number): Promise<boolean>;
1017
+ /** Registers a notification category (its action buttons). Optional: older fakes leave it out. */
1018
+ setNotificationCategoryAsync?(identifier: string, actions: NotificationActionLike[], options?: {
1019
+ previewPlaceholder?: string;
1020
+ }): Promise<unknown>;
993
1021
  getBadgeCountAsync(): Promise<number>;
994
1022
  /** `"expo.modules.notifications.actions.DEFAULT"`: the person tapped the notification itself. */
995
1023
  DEFAULT_ACTION_IDENTIFIER: string;
@@ -1007,7 +1035,7 @@ interface PushAppStateLike {
1007
1035
  };
1008
1036
  }
1009
1037
  /** Where a reported error came from. */
1010
- type PushErrorSource = "permission" | "register" | "forget" | "preferences" | "badge" | "channels" | "routing" | "storage" | "setup";
1038
+ type PushErrorSource = "permission" | "register" | "forget" | "preferences" | "badge" | "channels" | "categories" | "routing" | "storage" | "setup";
1011
1039
  type PushOnError = (error: Error, source: PushErrorSource) => void;
1012
1040
 
1013
1041
  /**
@@ -1176,6 +1204,8 @@ declare function createRegistration(options: {
1176
1204
  /** Is this user still the one signed in? A sync for someone who has signed out is dropped. */
1177
1205
  isCurrent: (userId: string) => boolean;
1178
1206
  now: () => number;
1207
+ /** Makes this install's id (once; it is kept). Default `crypto.randomUUID`. */
1208
+ randomUUID?: () => string;
1179
1209
  onError?: PushOnError;
1180
1210
  }): Registration;
1181
1211
 
@@ -1200,6 +1230,22 @@ interface PushChannel {
1200
1230
  description?: string;
1201
1231
  importance?: ChannelImportance;
1202
1232
  }
1233
+ /** A notification category and its buttons. */
1234
+ interface PushDeviceCategory {
1235
+ identifier: string;
1236
+ actions: readonly {
1237
+ id: string;
1238
+ title: string;
1239
+ textInput?: {
1240
+ placeholder?: string;
1241
+ submitTitle?: string;
1242
+ };
1243
+ opensApp?: boolean;
1244
+ destructive?: boolean;
1245
+ requiresAuth?: boolean;
1246
+ }[];
1247
+ previewPlaceholder?: string;
1248
+ }
1203
1249
  type PushSlots = {
1204
1250
  /** The soft pre-prompt, shown after `usePushPrompt().offer()`. Default `PushPromptSheet`. */
1205
1251
  pushPrompt: PushPromptProps;
@@ -1219,6 +1265,15 @@ interface PushOptions {
1219
1265
  categories?: readonly NotificationCategory[];
1220
1266
  /** More Android channels. A `default` channel is always made. */
1221
1267
  channels?: readonly PushChannel[];
1268
+ /**
1269
+ * Notification categories with action buttons (Reply, Mark as read),
1270
+ * registered on the device when the block starts. A push names one in its
1271
+ * `categoryId`; a tap on a button reaches `onResponse` (`notificationAction`
1272
+ * reads it). genesis-notifications' registry makes these (`deviceCategories()`).
1273
+ */
1274
+ deviceCategories?: readonly PushDeviceCategory[];
1275
+ /** Makes this install's id, for a backend that uses one (`usesInstallationId`). Default `crypto.randomUUID`; pass expo-crypto's on Hermes. */
1276
+ randomUUID?: () => string;
1222
1277
  /** AsyncStorage: this device's token (to forget it after sign-out), the prompt's "Not now"s. */
1223
1278
  storage?: PushStorageLike;
1224
1279
  /** `AppState`: read the permission again on coming to the front (the person may have changed it in Settings). */
@@ -1537,4 +1592,4 @@ declare function useBlockSettings<R extends SettingsRowLike>(sections: readonly
1537
1592
  /** The merge `useBlockSettings` does, without hooks (subtitles already read). @stability Beta */
1538
1593
  declare function mergeBlockSettings<R extends SettingsRowLike>(sections: readonly BlockSettingsSection<R>[], rows: readonly Contributed<BlockSettingsRow>[], subtitles?: readonly (string | null | undefined)[], options?: UseBlockSettingsOptions): BlockSettingsSection<R | SettingsRowLike>[];
1539
1594
 
1540
- export { defaultRejectionTracker as $, type AuthClientLike as A, type BlockSettingsSection as B, type SessionStatus as C, type DevtoolsFeedbackProps as D, type EnableRejectionTracker as E, FatalScreen as F, GenesisErrorBoundary as G, type HeldLinkAuth as H, type SessionStore as I, type SessionStoreOptions as J, type SettingsRowLike as K, type LinkHold as L, type SignedOutProps as M, type NativeBlock as N, type OnError as O, PlainFatalScreen as P, type SplashModule as Q, type UseBlockSettingsOptions as R, SessionProvider as S, type UseBootReturn as T, type UpdatesBlock as U, type VersionSignature as V, type UseHeldLinkOptions as W, createBoot as X, createLinkHold as Y, createSessionStore as Z, defaultErrorUtils as _, type DevtoolsSlots as a, usePushPrompt as a$, installGlobalErrorHandler as a0, installRejectionTracker as a1, mergeBlockSettings as a2, normalizeLink as a3, useBlockSettings as a4, useBlocks as a5, useBoot as a6, useHeldLink as a7, useSession as a8, type PushPermissionStatus as a9, type PushOnError as aA, type PushOptions as aB, type PushPromptProps as aC, PushPromptSheet as aD, PushProvider as aE, PushRoot as aF, type PushRootProps as aG, type PushSlots as aH, type PushStorageLike as aI, type PushSupabaseLike as aJ, type Registration as aK, type RegistrationState as aL, type RegistrationStatus as aM, type SubscriptionLike as aN, canPush as aO, createPermissionStore as aP, createPreferencesStore as aQ, createPromptStore as aR, createPush as aS, createRegistration as aT, statusOf as aU, supabasePushBackend as aV, useBadge as aW, useNotificationPreferences as aX, useOptionalPush as aY, usePush as aZ, usePushPermissionState as a_, type NotificationLike as aa, type NotificationResponseLike as ab, type QuietHours as ac, type ChannelImportance as ad, type ChannelInputLike as ae, DEFAULT_PREFERENCES as af, type NotificationBehaviorLike as ag, type NotificationCategory as ah, type NotificationPreferences as ai, type NotificationsModuleLike as aj, type PermissionRequestLike as ak, type PermissionState as al, type PermissionStatusLike as am, type PermissionStore as an, type PreferencesState as ao, type PreferencesStatus as ap, type PreferencesStore as aq, type PromptOptions as ar, type PromptState as as, type PromptStore as at, type PushAppStateLike as au, type PushBackend as av, PushBackendError as aw, type PushBlock as ax, type PushChannel as ay, type PushErrorSource as az, AuthGate as b, useMaintenance as b$, usePushRegistration as b0, useSuppressNotifications as b1, validPreferences as b2, type ApplicationLike as b3, CLOCK_SKEW_MS as b4, type ChannelSwitchResult as b5, type ChannelSwitcher as b6, type ChannelSwitcherOptions as b7, type EvaluatePolicyOptions as b8, type ForceUpgradeProps as b9, type UpdatesOptions as bA, UpdatesProvider as bB, UpdatesRoot as bC, type UpdatesRootProps as bD, type UpdatesSlots as bE, type UpdatesStorageLike as bF, type VersionSignatureInput as bG, WEEK_MS as bH, type WhatsNewOptions as bI, type WhatsNewProps as bJ, WhatsNewSheet as bK, type WhatsNewState as bL, type WhatsNewStore as bM, createChannelSwitcher as bN, createPolicyStore as bO, createReloadGuard as bP, createUpdateChecker as bQ, createUpdates as bR, createWhatsNewStore as bS, evaluatePolicy as bT, notesSince as bU, sanitizePolicy as bV, staticPolicySource as bW, supabasePolicySource as bX, useBlockReload as bY, useCanReload as bZ, useForceUpgrade as b_, ForceUpgradeScreen as ba, type MaintenanceProps as bb, MaintenanceScreen as bc, type PolicyClientLike as bd, type PolicyOrigin as be, type PolicyState as bf, type PolicyStore as bg, type PolicyStoreOptions as bh, type ReleaseNote as bi, type ReloadGuard as bj, type SupabasePolicySourceOptions as bk, UPDATES_BLOCK_ID as bl, UpdateBanner as bm, type UpdateBannerProps as bn, type UpdateCheckState as bo, type UpdateCheckStatus as bp, type UpdateChecker as bq, type UpdateCheckerOptions as br, type UpdatePlatform as bs, type UpdatePolicy as bt, type UpdatePolicySource as bu, type UpdateVerdict as bv, type UpdatesAppStateLike as bw, type UpdatesErrorSource as bx, type UpdatesModuleLike as by, type UpdatesOnError as bz, type AuthGateProps as c, useUpdateBanner as c0, useUpdateCheck as c1, useUpdatePolicy as c2, useUpdateVerdict as c3, useUpdates as c4, useVersionSignature as c5, useWhatsNew as c6, versionSignature as c7, type BootController as d, type BootOptions as e, BootProvider as f, BootScreen as g, type BootScreenProps as h, type BootState as i, type BootStatus as j, type BootStep as k, type BootStepContext as l, BootTimeoutError as m, type ErrorReport as n, type ErrorUtilsLike as o, type FatalScreenProps as p, type GenesisErrorBoundaryProps as q, GenesisNativeApp as r, type GenesisNativeAppProps as s, type GlobalErrorHandlerOptions as t, type LinkOptions as u, type LinkTarget as v, type NativeAppSlots as w, type NativeBlockRootProps as x, type NativeQueryClientOptions as y, type SessionState as z };
1595
+ export { defaultRejectionTracker as $, type AuthClientLike as A, type BlockSettingsSection as B, type SessionStatus as C, type DevtoolsFeedbackProps as D, type EnableRejectionTracker as E, FatalScreen as F, GenesisErrorBoundary as G, type HeldLinkAuth as H, type SessionStore as I, type SessionStoreOptions as J, type SettingsRowLike as K, type LinkHold as L, type SignedOutProps as M, type NativeBlock as N, type OnError as O, PlainFatalScreen as P, type SplashModule as Q, type UseBlockSettingsOptions as R, SessionProvider as S, type UseBootReturn as T, type UpdatesBlock as U, type VersionSignature as V, type UseHeldLinkOptions as W, createBoot as X, createLinkHold as Y, createSessionStore as Z, defaultErrorUtils as _, type DevtoolsSlots as a, usePush as a$, installGlobalErrorHandler as a0, installRejectionTracker as a1, mergeBlockSettings as a2, normalizeLink as a3, useBlockSettings as a4, useBlocks as a5, useBoot as a6, useHeldLink as a7, useSession as a8, type PushPermissionStatus as a9, type PushDeviceCategory as aA, type PushErrorSource as aB, type PushOnError as aC, type PushOptions as aD, type PushPromptProps as aE, PushPromptSheet as aF, PushProvider as aG, PushRoot as aH, type PushRootProps as aI, type PushSlots as aJ, type PushStorageLike as aK, type PushSupabaseLike as aL, type Registration as aM, type RegistrationState as aN, type RegistrationStatus as aO, type SubscriptionLike as aP, canPush as aQ, createPermissionStore as aR, createPreferencesStore as aS, createPromptStore as aT, createPush as aU, createRegistration as aV, statusOf as aW, supabasePushBackend as aX, useBadge as aY, useNotificationPreferences as aZ, useOptionalPush as a_, type NotificationResponseLike as aa, type NotificationLike as ab, type QuietHours as ac, type ChannelImportance as ad, type ChannelInputLike as ae, DEFAULT_PREFERENCES as af, type NotificationActionLike as ag, type NotificationBehaviorLike as ah, type NotificationCategory as ai, type NotificationPreferences as aj, type NotificationsModuleLike as ak, type PermissionRequestLike as al, type PermissionState as am, type PermissionStatusLike as an, type PermissionStore as ao, type PreferencesState as ap, type PreferencesStatus as aq, type PreferencesStore as ar, type PromptOptions as as, type PromptState as at, type PromptStore as au, type PushAppStateLike as av, type PushBackend as aw, PushBackendError as ax, type PushBlock as ay, type PushChannel as az, AuthGate as b, useCanReload as b$, usePushPermissionState as b0, usePushPrompt as b1, usePushRegistration as b2, useSuppressNotifications as b3, validPreferences as b4, type ApplicationLike as b5, CLOCK_SKEW_MS as b6, type ChannelSwitchResult as b7, type ChannelSwitcher as b8, type ChannelSwitcherOptions as b9, type UpdatesModuleLike as bA, type UpdatesOnError as bB, type UpdatesOptions as bC, UpdatesProvider as bD, UpdatesRoot as bE, type UpdatesRootProps as bF, type UpdatesSlots as bG, type UpdatesStorageLike as bH, type VersionSignatureInput as bI, WEEK_MS as bJ, type WhatsNewOptions as bK, type WhatsNewProps as bL, WhatsNewSheet as bM, type WhatsNewState as bN, type WhatsNewStore as bO, createChannelSwitcher as bP, createPolicyStore as bQ, createReloadGuard as bR, createUpdateChecker as bS, createUpdates as bT, createWhatsNewStore as bU, evaluatePolicy as bV, notesSince as bW, sanitizePolicy as bX, staticPolicySource as bY, supabasePolicySource as bZ, useBlockReload as b_, type EvaluatePolicyOptions as ba, type ForceUpgradeProps as bb, ForceUpgradeScreen as bc, type MaintenanceProps as bd, MaintenanceScreen as be, type PolicyClientLike as bf, type PolicyOrigin as bg, type PolicyState as bh, type PolicyStore as bi, type PolicyStoreOptions as bj, type ReleaseNote as bk, type ReloadGuard as bl, type SupabasePolicySourceOptions as bm, UPDATES_BLOCK_ID as bn, UpdateBanner as bo, type UpdateBannerProps as bp, type UpdateCheckState as bq, type UpdateCheckStatus as br, type UpdateChecker as bs, type UpdateCheckerOptions as bt, type UpdatePlatform as bu, type UpdatePolicy as bv, type UpdatePolicySource as bw, type UpdateVerdict as bx, type UpdatesAppStateLike as by, type UpdatesErrorSource as bz, type AuthGateProps as c, useForceUpgrade as c0, useMaintenance as c1, useUpdateBanner as c2, useUpdateCheck as c3, useUpdatePolicy as c4, useUpdateVerdict as c5, useUpdates as c6, useVersionSignature as c7, useWhatsNew as c8, versionSignature as c9, type BootController as d, type BootOptions as e, BootProvider as f, BootScreen as g, type BootScreenProps as h, type BootState as i, type BootStatus as j, type BootStep as k, type BootStepContext as l, BootTimeoutError as m, type ErrorReport as n, type ErrorUtilsLike as o, type FatalScreenProps as p, type GenesisErrorBoundaryProps as q, GenesisNativeApp as r, type GenesisNativeAppProps as s, type GlobalErrorHandlerOptions as t, type LinkOptions as u, type LinkTarget as v, type NativeAppSlots as w, type NativeBlockRootProps as x, type NativeQueryClientOptions as y, type SessionState as z };
@@ -0,0 +1,23 @@
1
+ // src/push/payload.ts
2
+ var RICH_PUSH_IMAGE_FIELDS = ["image", "senderAvatar"];
3
+ function isRichPushUrl(value) {
4
+ if (typeof value !== "string" || value.length > 1024) return false;
5
+ try {
6
+ const url = new URL(value);
7
+ return url.protocol === "https:" && url.hostname.length > 0 && !url.username && !url.password;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+ function richPushMessage(message, rich = {}) {
13
+ const data = { ...message.data ?? {} };
14
+ for (const key of RICH_PUSH_IMAGE_FIELDS) {
15
+ if (key in rich) data[key] = rich[key];
16
+ if (key in data && !isRichPushUrl(data[key])) delete data[key];
17
+ }
18
+ return { ...message, mutableContent: true, data };
19
+ }
20
+
21
+ export { RICH_PUSH_IMAGE_FIELDS, isRichPushUrl, richPushMessage };
22
+ //# sourceMappingURL=chunk-ONAXQHNG.js.map
23
+ //# sourceMappingURL=chunk-ONAXQHNG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/push/payload.ts"],"names":[],"mappings":";AA2BO,IAAM,sBAAA,GAAyB,CAAC,OAAA,EAAS,cAAc;AAOvD,SAAS,cAAc,KAAA,EAAiC;AAC7D,EAAA,IAAI,OAAO,KAAA,KAAU,QAAA,IAAY,KAAA,CAAM,MAAA,GAAS,MAAM,OAAO,KAAA;AAC7D,EAAA,IAAI;AACF,IAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,KAAK,CAAA;AACzB,IAAA,OAAO,GAAA,CAAI,QAAA,KAAa,QAAA,IAAY,GAAA,CAAI,QAAA,CAAS,MAAA,GAAS,CAAA,IAAK,CAAC,GAAA,CAAI,QAAA,IAAY,CAAC,GAAA,CAAI,QAAA;AAAA,EACvF,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,KAAA;AAAA,EACT;AACF;AAQO,SAAS,eAAA,CACd,OAAA,EACA,IAAA,GAAqB,EAAC,EACwE;AAC9F,EAAA,MAAM,OAAgC,EAAE,GAAI,OAAA,CAAQ,IAAA,IAAQ,EAAC,EAAG;AAChE,EAAA,KAAA,MAAW,OAAO,sBAAA,EAAwB;AACxC,IAAA,IAAI,OAAO,IAAA,EAAM,IAAA,CAAK,GAAG,CAAA,GAAI,KAAK,GAAG,CAAA;AACrC,IAAA,IAAI,GAAA,IAAO,IAAA,IAAQ,CAAC,aAAA,CAAc,IAAA,CAAK,GAAG,CAAC,CAAA,EAAG,OAAO,IAAA,CAAK,GAAG,CAAA;AAAA,EAC/D;AACA,EAAA,OAAO,EAAE,GAAG,OAAA,EAAS,cAAA,EAAgB,MAAM,IAAA,EAAK;AAClD","file":"chunk-ONAXQHNG.js","sourcesContent":["/**\n * Rich push payloads: what a sender adds to an Expo push message so the iOS\n * Notification Service Extension (`@marktiderman/genesis-native/rich-push-plugin`)\n * runs on it. No React, no React Native, no Node APIs: a server, an edge\n * function or Deno imports it from `@marktiderman/genesis-native/push-payload`.\n *\n * await expo.send([richPushMessage({ to, title, body, data: { url } }, { image })]);\n *\n * iOS runs the extension only when the push says `mutable-content: 1`, which\n * Expo's push service sets from `mutableContent: true`. Without it the\n * notification arrives as sent and the extension never sees it.\n */\n\n/** The image fields the extension reads from `data`. */\nexport interface RichPushData {\n /** An https image to attach (a photo in the message). Anything but https is dropped. */\n image?: string | null | undefined;\n /** The sender's photo, https. On a `messages` push it is the sender's avatar; on any other it is attached. */\n senderAvatar?: string | null | undefined;\n}\n\n/** The part of an Expo push message this touches. */\nexport interface RichPushMessageLike {\n data?: Record<string, unknown> | undefined;\n mutableContent?: boolean | undefined;\n}\n\nexport const RICH_PUSH_IMAGE_FIELDS = [\"image\", \"senderAvatar\"] as const;\n\n/**\n * The extension downloads only https URLs (no credentials, at most 1024\n * characters: APNs takes 4 KB in all); this is the same check. The plugin's\n * `imageHosts`, when set, narrows it on the phone.\n */\nexport function isRichPushUrl(value: unknown): value is string {\n if (typeof value !== \"string\" || value.length > 1024) return false;\n try {\n const url = new URL(value);\n return url.protocol === \"https:\" && url.hostname.length > 0 && !url.username && !url.password;\n } catch {\n return false;\n }\n}\n\n/**\n * The message with `mutableContent: true` and the image fields in its `data`.\n * A field given in `rich` replaces the one in `data`; one that isn't an https\n * URL is left out (the extension would ignore it). `data`'s other fields are\n * kept as they are.\n */\nexport function richPushMessage<M extends RichPushMessageLike>(\n message: M,\n rich: RichPushData = {},\n): Omit<M, \"mutableContent\" | \"data\"> & { mutableContent: true; data: Record<string, unknown> } {\n const data: Record<string, unknown> = { ...(message.data ?? {}) };\n for (const key of RICH_PUSH_IMAGE_FIELDS) {\n if (key in rich) data[key] = rich[key];\n if (key in data && !isRichPushUrl(data[key])) delete data[key];\n }\n return { ...message, mutableContent: true, data };\n}\n"]}
@@ -1,6 +1,6 @@
1
- import { N as NativeBlock, U as UpdatesBlock, V as VersionSignature } from './blocks-a1YQ1Elf.js';
2
- export { D as DevtoolsFeedbackProps, a as DevtoolsSlots } from './blocks-a1YQ1Elf.js';
3
- import { O as ObservabilityBlock, R as RecentActivity } from './block-C5-noMhB.js';
1
+ import { N as NativeBlock, U as UpdatesBlock, V as VersionSignature } from './blocks-C2RDpvaN.js';
2
+ export { D as DevtoolsFeedbackProps, a as DevtoolsSlots } from './blocks-C2RDpvaN.js';
3
+ import { O as ObservabilityBlock, R as RecentActivity } from './block-Onqx7NiK.js';
4
4
  import 'react';
5
5
  import '@marktiderman/genesis-core/slots';
6
6
  import '@marktiderman/genesis-features/blocks';
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { A as AuthClientLike, b as AuthGate, c as AuthGateProps, B as BlockSettingsSection, d as BootController, e as BootOptions, f as BootProvider, g as BootScreen, h as BootScreenProps, i as BootState, j as BootStatus, k as BootStep, l as BootStepContext, m as BootTimeoutError, E as EnableRejectionTracker, n as ErrorReport, o as ErrorUtilsLike, F as FatalScreen, p as FatalScreenProps, G as GenesisErrorBoundary, q as GenesisErrorBoundaryProps, r as GenesisNativeApp, s as GenesisNativeAppProps, t as GlobalErrorHandlerOptions, H as HeldLinkAuth, L as LinkHold, u as LinkOptions, v as LinkTarget, w as NativeAppSlots, N as NativeBlock, x as NativeBlockRootProps, y as NativeQueryClientOptions, O as OnError, P as PlainFatalScreen, S as SessionProvider, z as SessionState, C as SessionStatus, I as SessionStore, J as SessionStoreOptions, K as SettingsRowLike, M as SignedOutProps, Q as SplashModule, R as UseBlockSettingsOptions, T as UseBootReturn, W as UseHeldLinkOptions, X as createBoot, Y as createLinkHold, Z as createSessionStore, _ as defaultErrorUtils, $ as defaultRejectionTracker, a0 as installGlobalErrorHandler, a1 as installRejectionTracker, a2 as mergeBlockSettings, a3 as normalizeLink, a4 as useBlockSettings, a5 as useBlocks, a6 as useBoot, a7 as useHeldLink, a8 as useSession } from './blocks-a1YQ1Elf.js';
1
+ export { A as AuthClientLike, b as AuthGate, c as AuthGateProps, B as BlockSettingsSection, d as BootController, e as BootOptions, f as BootProvider, g as BootScreen, h as BootScreenProps, i as BootState, j as BootStatus, k as BootStep, l as BootStepContext, m as BootTimeoutError, E as EnableRejectionTracker, n as ErrorReport, o as ErrorUtilsLike, F as FatalScreen, p as FatalScreenProps, G as GenesisErrorBoundary, q as GenesisErrorBoundaryProps, r as GenesisNativeApp, s as GenesisNativeAppProps, t as GlobalErrorHandlerOptions, H as HeldLinkAuth, L as LinkHold, u as LinkOptions, v as LinkTarget, w as NativeAppSlots, N as NativeBlock, x as NativeBlockRootProps, y as NativeQueryClientOptions, O as OnError, P as PlainFatalScreen, S as SessionProvider, z as SessionState, C as SessionStatus, I as SessionStore, J as SessionStoreOptions, K as SettingsRowLike, M as SignedOutProps, Q as SplashModule, R as UseBlockSettingsOptions, T as UseBootReturn, W as UseHeldLinkOptions, X as createBoot, Y as createLinkHold, Z as createSessionStore, _ as defaultErrorUtils, $ as defaultRejectionTracker, a0 as installGlobalErrorHandler, a1 as installRejectionTracker, a2 as mergeBlockSettings, a3 as normalizeLink, a4 as useBlockSettings, a5 as useBlocks, a6 as useBoot, a7 as useHeldLink, a8 as useSession } from './blocks-C2RDpvaN.js';
2
2
  import { A as AsyncStorageLike } from './query-DUmVAfsF.js';
3
3
  export { a as AppStateLike, D as DAY_MS, N as NetInfoLike, Q as QueryLifecycleOptions, b as QueryPersistence, c as connectQueryLifecycle, d as createNativeQueryClient } from './query-DUmVAfsF.js';
4
4
  import 'react';
@@ -1,7 +1,7 @@
1
- import { a as ObservabilitySink } from './block-C5-noMhB.js';
2
- export { B as Breadcrumb, b as BreadcrumbLevel, C as ConsoleSinkOptions, E as ErrorContext, c as EventCheck, d as EventPropSpec, e as EventProps, f as EventSchema, g as EventValues, h as OBSERVABILITY_BLOCK_ID, O as ObservabilityBlock, i as ObservabilityOptions, j as ObservabilityProvider, k as ObserveLike, l as ObserveSinkOptions, R as RecentActivity, m as RecentError, n as RecentStore, T as TimingTags, o as checkEvent, p as consoleSink, q as createObservability, r as defineEvents, s as observeSink, t as scrubBreadcrumb, u as scrubbedError, v as useObservability, w as useRecentActivity } from './block-C5-noMhB.js';
1
+ import { a as ObservabilitySink } from './block-Onqx7NiK.js';
2
+ export { B as Breadcrumb, b as BreadcrumbLevel, C as ConsoleSinkOptions, E as ErrorContext, c as EventCheck, d as EventPropSpec, e as EventProps, f as EventSchema, g as EventValues, h as OBSERVABILITY_BLOCK_ID, O as ObservabilityBlock, i as ObservabilityOptions, j as ObservabilityProvider, k as ObserveLike, l as ObserveSinkOptions, R as RecentActivity, m as RecentError, n as RecentStore, T as TimingTags, o as checkEvent, p as consoleSink, q as createObservability, r as defineEvents, s as observeSink, t as scrubBreadcrumb, u as scrubbedError, v as useObservability, w as useRecentActivity } from './block-Onqx7NiK.js';
3
3
  import { ComponentType } from 'react';
4
- import { V as VersionSignature } from './blocks-a1YQ1Elf.js';
4
+ import { V as VersionSignature } from './blocks-C2RDpvaN.js';
5
5
  import '@marktiderman/genesis-core/slots';
6
6
  import '@marktiderman/genesis-features/blocks';
7
7
  import '@tanstack/react-query';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Rich push payloads: what a sender adds to an Expo push message so the iOS
3
+ * Notification Service Extension (`@marktiderman/genesis-native/rich-push-plugin`)
4
+ * runs on it. No React, no React Native, no Node APIs: a server, an edge
5
+ * function or Deno imports it from `@marktiderman/genesis-native/push-payload`.
6
+ *
7
+ * await expo.send([richPushMessage({ to, title, body, data: { url } }, { image })]);
8
+ *
9
+ * iOS runs the extension only when the push says `mutable-content: 1`, which
10
+ * Expo's push service sets from `mutableContent: true`. Without it the
11
+ * notification arrives as sent and the extension never sees it.
12
+ */
13
+ /** The image fields the extension reads from `data`. */
14
+ interface RichPushData {
15
+ /** An https image to attach (a photo in the message). Anything but https is dropped. */
16
+ image?: string | null | undefined;
17
+ /** The sender's photo, https. On a `messages` push it is the sender's avatar; on any other it is attached. */
18
+ senderAvatar?: string | null | undefined;
19
+ }
20
+ /** The part of an Expo push message this touches. */
21
+ interface RichPushMessageLike {
22
+ data?: Record<string, unknown> | undefined;
23
+ mutableContent?: boolean | undefined;
24
+ }
25
+ declare const RICH_PUSH_IMAGE_FIELDS: readonly ["image", "senderAvatar"];
26
+ /**
27
+ * The extension downloads only https URLs (no credentials, at most 1024
28
+ * characters: APNs takes 4 KB in all); this is the same check. The plugin's
29
+ * `imageHosts`, when set, narrows it on the phone.
30
+ */
31
+ declare function isRichPushUrl(value: unknown): value is string;
32
+ /**
33
+ * The message with `mutableContent: true` and the image fields in its `data`.
34
+ * A field given in `rich` replaces the one in `data`; one that isn't an https
35
+ * URL is left out (the extension would ignore it). `data`'s other fields are
36
+ * kept as they are.
37
+ */
38
+ declare function richPushMessage<M extends RichPushMessageLike>(message: M, rich?: RichPushData): Omit<M, "mutableContent" | "data"> & {
39
+ mutableContent: true;
40
+ data: Record<string, unknown>;
41
+ };
42
+
43
+ export { RICH_PUSH_IMAGE_FIELDS, type RichPushData, type RichPushMessageLike, isRichPushUrl, richPushMessage };
@@ -0,0 +1,3 @@
1
+ export { RICH_PUSH_IMAGE_FIELDS, isRichPushUrl, richPushMessage } from './chunk-ONAXQHNG.js';
2
+ //# sourceMappingURL=push-payload.js.map
3
+ //# sourceMappingURL=push-payload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"push-payload.js"}
package/dist/push.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { a9 as PushPermissionStatus, aa as NotificationLike, ab as NotificationResponseLike, u as LinkOptions, ac as QuietHours } from './blocks-a1YQ1Elf.js';
2
- export { ad as ChannelImportance, ae as ChannelInputLike, af as DEFAULT_PREFERENCES, ag as NotificationBehaviorLike, ah as NotificationCategory, ai as NotificationPreferences, aj as NotificationsModuleLike, ak as PermissionRequestLike, al as PermissionState, am as PermissionStatusLike, an as PermissionStore, ao as PreferencesState, ap as PreferencesStatus, aq as PreferencesStore, ar as PromptOptions, as as PromptState, at as PromptStore, au as PushAppStateLike, av as PushBackend, aw as PushBackendError, ax as PushBlock, ay as PushChannel, az as PushErrorSource, aA as PushOnError, aB as PushOptions, aC as PushPromptProps, aD as PushPromptSheet, aE as PushProvider, aF as PushRoot, aG as PushRootProps, aH as PushSlots, aI as PushStorageLike, aJ as PushSupabaseLike, aK as Registration, aL as RegistrationState, aM as RegistrationStatus, aN as SubscriptionLike, aO as canPush, aP as createPermissionStore, aQ as createPreferencesStore, aR as createPromptStore, aS as createPush, aT as createRegistration, aU as statusOf, aV as supabasePushBackend, aW as useBadge, aX as useNotificationPreferences, aY as useOptionalPush, aZ as usePush, a_ as usePushPermissionState, a$ as usePushPrompt, b0 as usePushRegistration, b1 as useSuppressNotifications, b2 as validPreferences } from './blocks-a1YQ1Elf.js';
1
+ import { a9 as PushPermissionStatus, aa as NotificationResponseLike, ab as NotificationLike, u as LinkOptions, ac as QuietHours } from './blocks-C2RDpvaN.js';
2
+ export { ad as ChannelImportance, ae as ChannelInputLike, af as DEFAULT_PREFERENCES, ag as NotificationActionLike, ah as NotificationBehaviorLike, ai as NotificationCategory, aj as NotificationPreferences, ak as NotificationsModuleLike, al as PermissionRequestLike, am as PermissionState, an as PermissionStatusLike, ao as PermissionStore, ap as PreferencesState, aq as PreferencesStatus, ar as PreferencesStore, as as PromptOptions, at as PromptState, au as PromptStore, av as PushAppStateLike, aw as PushBackend, ax as PushBackendError, ay as PushBlock, az as PushChannel, aA as PushDeviceCategory, aB as PushErrorSource, aC as PushOnError, aD as PushOptions, aE as PushPromptProps, aF as PushPromptSheet, aG as PushProvider, aH as PushRoot, aI as PushRootProps, aJ as PushSlots, aK as PushStorageLike, aL as PushSupabaseLike, aM as Registration, aN as RegistrationState, aO as RegistrationStatus, aP as SubscriptionLike, aQ as canPush, aR as createPermissionStore, aS as createPreferencesStore, aT as createPromptStore, aU as createPush, aV as createRegistration, aW as statusOf, aX as supabasePushBackend, aY as useBadge, aZ as useNotificationPreferences, a_ as useOptionalPush, a$ as usePush, b0 as usePushPermissionState, b1 as usePushPrompt, b2 as usePushRegistration, b3 as useSuppressNotifications, b4 as validPreferences } from './blocks-C2RDpvaN.js';
3
3
  import * as react from 'react';
4
4
  import { ReactNode } from 'react';
5
5
  import { Slots, SlotPropsFor } from '@marktiderman/genesis-core/slots';
6
6
  import { SettingsPageProps, SettingsPageRow } from '@marktiderman/genesis-ui-native/layouts';
7
+ export { RICH_PUSH_IMAGE_FIELDS, RichPushData, RichPushMessageLike, isRichPushUrl, richPushMessage } from './push-payload.js';
7
8
  import '@marktiderman/genesis-features/blocks';
8
9
  import '@tanstack/react-query';
9
10
  import '@marktiderman/genesis-design-system';
@@ -139,6 +140,23 @@ declare function NotificationSettings({ copy: overrides, slots, slotProps, page,
139
140
  declare function notificationData(notification: NotificationLike): Record<string, unknown>;
140
141
  /** The app path a tap should open, or null. */
141
142
  declare function pathForResponse(response: NotificationResponseLike, links?: LinkOptions): string | null;
143
+ /** A notification action (a button such as Reply or Mark as read), as the app handles it. */
144
+ interface NotificationAction {
145
+ /** The button's id (`reply`, `mark_read`); the plain tap is not an action. */
146
+ actionId: string;
147
+ /** The notifier's id for the notification (`data.notification_id`): use it as the reply's client id, so a retried reply stays one reply. */
148
+ notificationId: string | null;
149
+ /** What the person typed, for a text-input action. */
150
+ text: string | null;
151
+ /** `data.url`, the screen it belongs to (`/messages/<conversation>`). */
152
+ url: string | null;
153
+ data: Record<string, unknown>;
154
+ }
155
+ /**
156
+ * The action in a response, or null for a plain tap (`defaultActionId`,
157
+ * expo-notifications' `DEFAULT_ACTION_IDENTIFIER`) or a dismissal.
158
+ */
159
+ declare function notificationAction(response: NotificationResponseLike, defaultActionId?: string): NotificationAction | null;
142
160
 
143
161
  /**
144
162
  * Quiet hours on the phone: only for showing them ("on now, until 07:00").
@@ -166,4 +184,4 @@ declare function minutesIn(timeZone: string, at: Date): number | null;
166
184
  */
167
185
  declare function isQuietAt(quiet: QuietHours, at?: Date): boolean;
168
186
 
169
- export { type CategoriesPartProps, type CategoryRow, NotificationLike, NotificationResponseLike, NotificationSettings, type NotificationSettingsCopy, type NotificationSettingsProps, type NotificationSettingsSlots, type PermissionPartProps, type PrivacyPartProps, PushPermissionStatus, QuietHours, type QuietHoursPartProps, categoryRows, clockOf, dateAt, deviceTimeZone, formatClock, isQuietAt, minutesIn, notificationData, notificationSettingsCopy, parseClock, pathForResponse, permissionRows, privacyRows, quietHoursRows, useNotificationCategories, usePrivacyPreviews, usePushPermission, useQuietHours };
187
+ export { type CategoriesPartProps, type CategoryRow, type NotificationAction, NotificationLike, NotificationResponseLike, NotificationSettings, type NotificationSettingsCopy, type NotificationSettingsProps, type NotificationSettingsSlots, type PermissionPartProps, type PrivacyPartProps, PushPermissionStatus, QuietHours, type QuietHoursPartProps, categoryRows, clockOf, dateAt, deviceTimeZone, formatClock, isQuietAt, minutesIn, notificationAction, notificationData, notificationSettingsCopy, parseClock, pathForResponse, permissionRows, privacyRows, quietHoursRows, useNotificationCategories, usePrivacyPreviews, usePushPermission, useQuietHours };
package/dist/push.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { normalizeLink } from './chunk-VKTIHNAV.js';
2
2
  import { createStore, delay } from './chunk-XE7LC7K6.js';
3
+ export { RICH_PUSH_IMAGE_FIELDS, isRichPushUrl, richPushMessage } from './chunk-ONAXQHNG.js';
3
4
  import { useSession } from './chunk-NGYNYJPL.js';
4
5
  import { createContext, useSyncExternalStore, useContext, useRef, useEffect, useMemo } from 'react';
5
6
  import { View } from 'react-native';
@@ -321,11 +322,28 @@ function createPreferencesStore(options) {
321
322
  }
322
323
 
323
324
  // src/push/registration.ts
325
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
326
+ function makeUUID(maker) {
327
+ try {
328
+ const made = maker?.() ?? globalThis.crypto?.randomUUID?.();
329
+ if (typeof made === "string" && UUID.test(made)) return made.toLowerCase();
330
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
331
+ if (!bytes) return void 0;
332
+ bytes[6] = bytes[6] & 15 | 64;
333
+ bytes[8] = bytes[8] & 63 | 128;
334
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
335
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
336
+ } catch {
337
+ return void 0;
338
+ }
339
+ }
324
340
  var REFRESH_MS = 60 * 6e4;
325
341
  function createRegistration(options) {
326
342
  const { notifications, backend, storage, storageKeyPrefix, onError, now } = options;
327
343
  const savedKey = `${storageKeyPrefix}:device`;
328
344
  const pendingKey = `${storageKeyPrefix}:forget`;
345
+ const installationKey = `${storageKeyPrefix}:installation`;
346
+ let installation = null;
329
347
  const store = createStore({ status: "idle", problem: null });
330
348
  let current = null;
331
349
  let queue = Promise.resolve();
@@ -354,6 +372,16 @@ function createRegistration(options) {
354
372
  report(onError, error, "storage");
355
373
  }
356
374
  };
375
+ const installationId = async () => {
376
+ if (installation) return installation;
377
+ const saved = await read(installationKey, null);
378
+ if (typeof saved === "string" && UUID.test(saved)) return installation = saved;
379
+ const made = makeUUID(options.randomUUID);
380
+ if (!made) return void 0;
381
+ installation = made;
382
+ await write(installationKey, made);
383
+ return made;
384
+ };
357
385
  const forgetToken = async (token) => {
358
386
  try {
359
387
  await backend.forget(token);
@@ -404,7 +432,9 @@ function createRegistration(options) {
404
432
  }
405
433
  if (!options.isCurrent(userId)) return;
406
434
  try {
407
- await backend.register(token, options.platform === "ios" ? "ios" : "android", options.appVersion ?? null);
435
+ const platform = options.platform === "ios" ? "ios" : "android";
436
+ if (backend.usesInstallationId) await backend.register(token, platform, options.appVersion ?? null, await installationId());
437
+ else await backend.register(token, platform, options.appVersion ?? null);
408
438
  } catch (error) {
409
439
  report(onError, error, "register");
410
440
  store.setState({ status: "error", problem: "backend" });
@@ -457,6 +487,18 @@ function pathForResponse(response, links) {
457
487
  const path = normalizeLink(url, ANY_PATH);
458
488
  return path === "/" && url.split(/[?#]/)[0] !== "/" ? null : path;
459
489
  }
490
+ function notificationAction(response, defaultActionId = "expo.modules.notifications.actions.DEFAULT") {
491
+ const id = response.actionIdentifier;
492
+ if (!id || id === defaultActionId || id === "com.apple.UNNotificationDismissActionIdentifier") return null;
493
+ const data = notificationData(response.notification);
494
+ return {
495
+ actionId: id,
496
+ notificationId: typeof data.notification_id === "string" ? data.notification_id : null,
497
+ text: typeof response.userText === "string" ? response.userText : null,
498
+ url: typeof data.url === "string" ? data.url : null,
499
+ data
500
+ };
501
+ }
460
502
  var PROMPT_CONTENT = { flexGrow: 1, justifyContent: "center", gap: 16, padding: 24 };
461
503
  function PushPromptSheet({ reason, accept, decline, title, body, acceptLabel, declineLabel }) {
462
504
  return /* @__PURE__ */ jsx(NativeSheet, { open: true, onOpenChange: (open) => open ? void 0 : decline(), testID: "genesis-push-prompt", children: /* @__PURE__ */ jsxs(View, { style: PROMPT_CONTENT, testID: "genesis-push-prompt-content", children: [
@@ -508,6 +550,30 @@ function createPush(options) {
508
550
  });
509
551
  return channelsMade;
510
552
  };
553
+ let categoriesMade = false;
554
+ const registerDeviceCategories = async () => {
555
+ const set = notifications?.setNotificationCategoryAsync;
556
+ if (categoriesMade || !notifications || !set || !options.deviceCategories?.length) return;
557
+ categoriesMade = true;
558
+ for (const c of options.deviceCategories) {
559
+ try {
560
+ await set.call(
561
+ notifications,
562
+ c.identifier,
563
+ c.actions.map((a) => ({
564
+ identifier: a.id,
565
+ buttonTitle: a.title,
566
+ ...a.textInput ? { textInput: { placeholder: a.textInput.placeholder ?? "", submitButtonTitle: a.textInput.submitTitle ?? a.title } } : {},
567
+ options: { opensAppToForeground: a.opensApp ?? false, isDestructive: a.destructive ?? false, isAuthenticationRequired: a.requiresAuth ?? false }
568
+ })),
569
+ c.previewPlaceholder ? { previewPlaceholder: c.previewPlaceholder } : void 0
570
+ );
571
+ } catch (error) {
572
+ categoriesMade = false;
573
+ report(onError, error, "categories");
574
+ }
575
+ }
576
+ };
511
577
  const permission = createPermissionStore({ notifications, request: options.permissions, beforeRequest: ensureChannels, onError });
512
578
  const registration = createRegistration({
513
579
  notifications,
@@ -520,6 +586,7 @@ function createPush(options) {
520
586
  ensureChannels,
521
587
  isCurrent: (id) => userId === id,
522
588
  now,
589
+ randomUUID: options.randomUUID,
523
590
  onError
524
591
  });
525
592
  const preferences = createPreferencesStore({ backend: options.backend, categories, onError });
@@ -637,8 +704,14 @@ function createPush(options) {
637
704
  });
638
705
  void registration.flush();
639
706
  void permission.refresh();
640
- const tokenSubscription = notifications.addPushTokenListener(() => {
641
- if (userId) void registration.sync(userId, { force: true });
707
+ void registerDeviceCategories();
708
+ let deviceToken;
709
+ const tokenSubscription = notifications.addPushTokenListener((token) => {
710
+ const next = token?.data;
711
+ const seen = deviceToken;
712
+ deviceToken = next;
713
+ if (!userId || next === seen) return;
714
+ void registration.sync(userId, { force: seen !== void 0 });
642
715
  });
643
716
  const appSubscription = options.appState?.addEventListener("change", (state) => {
644
717
  if (state !== "active") return;
@@ -991,6 +1064,6 @@ function NotificationSettings({ copy: overrides, slots = {}, slotProps = {}, pag
991
1064
  return /* @__PURE__ */ jsx(SettingsPage, { testID, ...page, sections, children });
992
1065
  }
993
1066
 
994
- export { DEFAULT_PREFERENCES, NotificationSettings, PushBackendError, PushPromptSheet, PushProvider, PushRoot, canPush, categoryRows, clockOf, createPermissionStore, createPreferencesStore, createPromptStore, createPush, createRegistration, dateAt, deviceTimeZone, formatClock, isQuietAt, minutesIn, notificationData, notificationSettingsCopy, parseClock, pathForResponse, permissionRows, privacyRows, quietHoursRows, statusOf, supabasePushBackend, useBadge, useNotificationCategories, useNotificationPreferences, useOptionalPush, usePrivacyPreviews, usePush, usePushPermission, usePushPermissionState, usePushPrompt, usePushRegistration, useQuietHours, useSuppressNotifications, validPreferences };
1067
+ export { DEFAULT_PREFERENCES, NotificationSettings, PushBackendError, PushPromptSheet, PushProvider, PushRoot, canPush, categoryRows, clockOf, createPermissionStore, createPreferencesStore, createPromptStore, createPush, createRegistration, dateAt, deviceTimeZone, formatClock, isQuietAt, minutesIn, notificationAction, notificationData, notificationSettingsCopy, parseClock, pathForResponse, permissionRows, privacyRows, quietHoursRows, statusOf, supabasePushBackend, useBadge, useNotificationCategories, useNotificationPreferences, useOptionalPush, usePrivacyPreviews, usePush, usePushPermission, usePushPermissionState, usePushPrompt, usePushRegistration, useQuietHours, useSuppressNotifications, validPreferences };
995
1068
  //# sourceMappingURL=push.js.map
996
1069
  //# sourceMappingURL=push.js.map