@marktiderman/genesis-native 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marktiderman/genesis-native",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -42,6 +42,11 @@
42
42
  "react-native": "./src/push/index.ts",
43
43
  "import": "./dist/push.js"
44
44
  },
45
+ "./push-payload": {
46
+ "types": "./dist/push-payload.d.ts",
47
+ "import": "./dist/push-payload.js",
48
+ "default": "./dist/push-payload.js"
49
+ },
45
50
  "./observability": {
46
51
  "types": "./dist/observability.d.ts",
47
52
  "react-native": "./src/observability/index.ts",
@@ -56,24 +61,31 @@
56
61
  "types": "./dist/dev-warnings.d.ts",
57
62
  "react-native": "./src/dev-warnings.ts",
58
63
  "import": "./dist/dev-warnings.js"
64
+ },
65
+ "./rich-push-plugin": {
66
+ "types": "./dist/rich-push-plugin.d.ts",
67
+ "import": "./dist/rich-push-plugin.js",
68
+ "default": "./dist/rich-push-plugin.js"
59
69
  }
60
70
  },
61
71
  "files": [
62
72
  "dist",
63
73
  "src",
74
+ "ios",
64
75
  "!src/**/__tests__",
65
76
  "README.md"
66
77
  ],
67
78
  "dependencies": {
68
- "@marktiderman/genesis-core": "^1.7.0",
79
+ "@marktiderman/genesis-core": "^1.9.0",
69
80
  "@marktiderman/genesis-design-system": "^1.5.0",
70
- "@marktiderman/genesis-features": "^0.3.0",
71
- "@marktiderman/genesis-ui-native": "^2.3.0"
81
+ "@marktiderman/genesis-features": "^0.4.0",
82
+ "@marktiderman/genesis-ui-native": "^2.4.1"
72
83
  },
73
84
  "peerDependencies": {
74
85
  "@tanstack/query-async-storage-persister": "^5.0.0",
75
86
  "@tanstack/query-persist-client-core": "^5.0.0",
76
87
  "@tanstack/react-query": "^5.0.0",
88
+ "expo": ">=54",
77
89
  "react": "^19.0.0",
78
90
  "react-native": ">=0.76.0",
79
91
  "react-native-safe-area-context": ">=4.0.0"
@@ -88,9 +100,15 @@
88
100
  "@tanstack/query-persist-client-core": {
89
101
  "optional": true
90
102
  },
103
+ "expo": {
104
+ "optional": true
105
+ },
91
106
  "expo-application": {
92
107
  "optional": true
93
108
  },
109
+ "expo-notifications": {
110
+ "optional": true
111
+ },
94
112
  "expo-observe": {
95
113
  "optional": true
96
114
  },
@@ -99,9 +117,6 @@
99
117
  },
100
118
  "expo-updates": {
101
119
  "optional": true
102
- },
103
- "expo-notifications": {
104
- "optional": true
105
120
  }
106
121
  },
107
122
  "devDependencies": {
@@ -111,6 +126,7 @@
111
126
  "@tanstack/react-query": "^5.75.0",
112
127
  "@testing-library/react": "^16.3.2",
113
128
  "@types/react": "^19.2.14",
129
+ "expo": "~58.0.19",
114
130
  "happy-dom": "^20.9.0",
115
131
  "react": "^19.2.5",
116
132
  "react-dom": "^19.2.5",
@@ -0,0 +1,312 @@
1
+ /**
2
+ * `@marktiderman/genesis-native/rich-push-plugin`: the Expo config plugin for
3
+ * iOS rich push. It adds a Notification Service Extension target (the Swift in
4
+ * `ios/notification-service/`) that downloads `data.image` / `data.senderAvatar`
5
+ * and turns `messages` pushes into Communication Notifications (the sender's
6
+ * photo, Focus treats them as messages), and it sets what they need:
7
+ *
8
+ * - the app: `com.apple.developer.usernotifications.communication` and
9
+ * `NSUserActivityTypes: [INSendMessageIntent]`;
10
+ * - the extension: its own bundle id (`<app>.NotificationService`), Info.plist
11
+ * and entitlements, embedded in the app;
12
+ * - EAS: `extra.eas.build.experimental.ios.appExtensions`, so EAS Build makes
13
+ * the extension's provisioning profile with the app's.
14
+ *
15
+ * plugins: [..., "@marktiderman/genesis-cli", "@marktiderman/genesis-native/rich-push-plugin"]
16
+ *
17
+ * List it after any plugin that changes the bundle id (the Genesis plugin's
18
+ * APP_VARIANT suffix): the extension's id is the app's plus a suffix, read
19
+ * when this plugin runs. Running it twice changes nothing.
20
+ */
21
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import * as path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ // Expo's own copy (the app's `expo`), so the mods run on the same config-plugins as prebuild.
25
+ import { withDangerousMod, withXcodeProject, type ConfigPlugin } from "expo/config-plugins";
26
+ import type { ExpoConfig } from "expo/config";
27
+
28
+ export const COMMUNICATION_ENTITLEMENT = "com.apple.developer.usernotifications.communication";
29
+ export const SEND_MESSAGE_INTENT = "INSendMessageIntent";
30
+ export const DEFAULT_TARGET_NAME = "GenesisNotificationService";
31
+ export const DEFAULT_BUNDLE_SUFFIX = "NotificationService";
32
+ const SWIFT_FILE = "NotificationService.swift";
33
+
34
+ export interface RichPushPluginOptions {
35
+ /** The extension's Xcode target and folder under ios/. Default `GenesisNotificationService`. */
36
+ targetName?: string;
37
+ /** Appended to the app's bundle id with a dot. Default `NotificationService`. */
38
+ bundleIdSuffix?: string;
39
+ /**
40
+ * Communication Notifications (the sender's photo, Focus): the entitlement and
41
+ * `NSUserActivityTypes`. Default true; false keeps image attachments only.
42
+ */
43
+ communication?: boolean;
44
+ /** The extension's minimum iOS. Default: the app target's. */
45
+ deploymentTarget?: string;
46
+ /**
47
+ * The hosts images may be downloaded from (`cdn.example.com`, or `example.com`
48
+ * for it and its subdomains). Unset, any https host: a sender who puts a URL
49
+ * a person chose (their avatar) in a push lets that host see each delivery.
50
+ */
51
+ imageHosts?: string[];
52
+ }
53
+
54
+ interface Resolved {
55
+ targetName: string;
56
+ bundleIdSuffix: string;
57
+ communication: boolean;
58
+ deploymentTarget: string | undefined;
59
+ imageHosts: string[];
60
+ }
61
+
62
+ function resolve(options: RichPushPluginOptions | undefined): Resolved {
63
+ const targetName = options?.targetName ?? DEFAULT_TARGET_NAME;
64
+ const bundleIdSuffix = options?.bundleIdSuffix ?? DEFAULT_BUNDLE_SUFFIX;
65
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(targetName)) throw new Error(`rich-push-plugin: targetName "${targetName}" must be letters, digits and _`);
66
+ if (!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/.test(bundleIdSuffix)) throw new Error(`rich-push-plugin: bundleIdSuffix "${bundleIdSuffix}" isn't a bundle id part`);
67
+ const imageHosts = (options?.imageHosts ?? []).map((h) => h.trim().toLowerCase());
68
+ for (const h of imageHosts) if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(h)) throw new Error(`rich-push-plugin: imageHosts "${h}" isn't a host name`);
69
+ return { targetName, bundleIdSuffix, communication: options?.communication ?? true, deploymentTarget: options?.deploymentTarget, imageHosts };
70
+ }
71
+
72
+ /** The extension's bundle id: the app's plus the suffix. */
73
+ export function extensionBundleId(appBundleId: string, suffix = DEFAULT_BUNDLE_SUFFIX): string {
74
+ return `${appBundleId}.${suffix}`;
75
+ }
76
+
77
+ /** The extension's entitlements (also what EAS provisions it with). */
78
+ export function extensionEntitlements(communication: boolean): Record<string, unknown> {
79
+ return communication ? { [COMMUNICATION_ENTITLEMENT]: true } : {};
80
+ }
81
+
82
+ /**
83
+ * The config-time part, visible in `expo config`: the app's entitlement and
84
+ * `NSUserActivityTypes`, and the extension for EAS. Fills gaps and appends;
85
+ * never removes what the app set.
86
+ */
87
+ export function applyRichPushConfig(config: ExpoConfig, options?: RichPushPluginOptions): ExpoConfig {
88
+ const o = resolve(options);
89
+ const bundleId = config.ios?.bundleIdentifier;
90
+ if (!bundleId) throw new Error("rich-push-plugin: ios.bundleIdentifier is required (the extension's id is built from it)");
91
+ const ios = { ...config.ios };
92
+ if (o.communication) {
93
+ ios.entitlements = { ...ios.entitlements, [COMMUNICATION_ENTITLEMENT]: true };
94
+ const types = Array.isArray(ios.infoPlist?.NSUserActivityTypes) ? (ios.infoPlist!.NSUserActivityTypes as unknown[]) : [];
95
+ ios.infoPlist = { ...ios.infoPlist, NSUserActivityTypes: types.includes(SEND_MESSAGE_INTENT) ? types : [...types, SEND_MESSAGE_INTENT] };
96
+ }
97
+ const extra = { ...config.extra };
98
+ const eas = { ...(extra.eas as Record<string, unknown> | undefined) };
99
+ const build = { ...(eas.build as Record<string, unknown> | undefined) };
100
+ const experimental = { ...(build.experimental as Record<string, unknown> | undefined) };
101
+ const easIos = { ...(experimental.ios as Record<string, unknown> | undefined) };
102
+ const existing = Array.isArray(easIos.appExtensions) ? (easIos.appExtensions as { targetName?: string }[]) : [];
103
+ const entry = { targetName: o.targetName, bundleIdentifier: extensionBundleId(bundleId, o.bundleIdSuffix), entitlements: extensionEntitlements(o.communication) };
104
+ easIos.appExtensions = [...existing.filter((e) => e?.targetName !== o.targetName), entry];
105
+ extra.eas = { ...eas, build: { ...build, experimental: { ...experimental, ios: easIos } } };
106
+ return { ...config, ios, extra };
107
+ }
108
+
109
+ /** The extension's Info.plist. Versions follow the app's (App Store Connect rejects a mismatch). */
110
+ export function extensionInfoPlist(displayName: string, options: { communication?: boolean; imageHosts?: string[] } = {}): string {
111
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
112
+ return `<?xml version="1.0" encoding="UTF-8"?>
113
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
114
+ <plist version="1.0">
115
+ <dict>
116
+ <key>CFBundleDevelopmentRegion</key>
117
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
118
+ <key>CFBundleDisplayName</key>
119
+ <string>${esc(displayName)}</string>
120
+ <key>CFBundleExecutable</key>
121
+ <string>$(EXECUTABLE_NAME)</string>
122
+ <key>CFBundleIdentifier</key>
123
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
124
+ <key>CFBundleInfoDictionaryVersion</key>
125
+ <string>6.0</string>
126
+ <key>CFBundleName</key>
127
+ <string>$(PRODUCT_NAME)</string>
128
+ <key>CFBundlePackageType</key>
129
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
130
+ <key>CFBundleShortVersionString</key>
131
+ <string>$(MARKETING_VERSION)</string>
132
+ <key>CFBundleVersion</key>
133
+ <string>$(CURRENT_PROJECT_VERSION)</string>
134
+ <key>NSExtension</key>
135
+ <dict>
136
+ <key>NSExtensionPointIdentifier</key>
137
+ <string>com.apple.usernotifications.service</string>
138
+ <key>NSExtensionPrincipalClass</key>
139
+ <string>$(PRODUCT_MODULE_NAME).NotificationService</string>${
140
+ options.communication
141
+ ? `
142
+ <key>NSExtensionAttributes</key>
143
+ <dict>
144
+ <key>IntentsSupported</key>
145
+ <array>
146
+ <string>${SEND_MESSAGE_INTENT}</string>
147
+ </array>
148
+ </dict>`
149
+ : ""
150
+ }
151
+ </dict>${
152
+ options.imageHosts?.length
153
+ ? `
154
+ <key>GenesisRichPushImageHosts</key>
155
+ <array>
156
+ ${options.imageHosts.map((h) => ` <string>${esc(h)}</string>`).join("\n")}
157
+ </array>`
158
+ : ""
159
+ }
160
+ </dict>
161
+ </plist>
162
+ `;
163
+ }
164
+
165
+ export function entitlementsPlist(entitlements: Record<string, unknown>): string {
166
+ const body = Object.entries(entitlements)
167
+ .map(([k, v]) => ` <key>${k}</key>\n <${v ? "true" : "false"}/>`)
168
+ .join("\n");
169
+ return `<?xml version="1.0" encoding="UTF-8"?>
170
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
171
+ <plist version="1.0">
172
+ <dict>
173
+ ${body}
174
+ </dict>
175
+ </plist>
176
+ `;
177
+ }
178
+
179
+ /** This package's root, found from this file (src/plugin in tests, dist when built). */
180
+ export function packageRoot(from = path.dirname(fileURLToPath(import.meta.url))): string {
181
+ let dir = from;
182
+ for (;;) {
183
+ const pkg = path.join(dir, "package.json");
184
+ if (existsSync(pkg) && (JSON.parse(readFileSync(pkg, "utf8")) as { name?: string }).name === "@marktiderman/genesis-native") return dir;
185
+ const parent = path.dirname(dir);
186
+ if (parent === dir) throw new Error("rich-push-plugin: can't find @marktiderman/genesis-native's folder");
187
+ dir = parent;
188
+ }
189
+ }
190
+
191
+ /** The extension's Swift source, shipped in this package. */
192
+ export function serviceSwiftPath(root = packageRoot()): string {
193
+ return path.join(root, "ios", "notification-service", SWIFT_FILE);
194
+ }
195
+
196
+ /** Writes the extension's folder under ios/: the Swift, its Info.plist and its entitlements. */
197
+ export function writeExtensionFiles(
198
+ iosRoot: string,
199
+ options: { targetName: string; displayName: string; communication: boolean; imageHosts?: string[]; swiftSource?: string },
200
+ ): void {
201
+ const dir = path.join(iosRoot, options.targetName);
202
+ mkdirSync(dir, { recursive: true });
203
+ copyFileSync(options.swiftSource ?? serviceSwiftPath(), path.join(dir, SWIFT_FILE));
204
+ writeFileSync(path.join(dir, "Info.plist"), extensionInfoPlist(options.displayName, { communication: options.communication, imageHosts: options.imageHosts ?? [] }));
205
+ writeFileSync(path.join(dir, `${options.targetName}.entitlements`), entitlementsPlist(extensionEntitlements(options.communication)));
206
+ }
207
+
208
+ /** The parts of the `xcode` package's project this uses. */
209
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
+ type XcodeProject = any;
211
+
212
+ const unquote = (v: unknown) => (typeof v === "string" ? v.replace(/^"(.*)"$/, "$1") : v);
213
+
214
+ /** The app target's build setting (its first configuration that has it). */
215
+ function appSetting(project: XcodeProject, key: string): string | undefined {
216
+ const app = project.getFirstTarget();
217
+ const listId = app?.firstTarget?.buildConfigurationList;
218
+ const lists = project.pbxXCConfigurationList();
219
+ const configs = project.pbxXCBuildConfigurationSection();
220
+ for (const ref of lists[listId]?.buildConfigurations ?? []) {
221
+ const value = configs[ref.value]?.buildSettings?.[key];
222
+ if (value !== undefined) return String(unquote(value));
223
+ }
224
+ return undefined;
225
+ }
226
+
227
+ /**
228
+ * Adds the extension target to the project (once): its group and files, the
229
+ * sources, frameworks and resources phases, its build settings, a dependency
230
+ * from the app and the app's phase that embeds it.
231
+ */
232
+ export function addNotificationServiceTarget(
233
+ project: XcodeProject,
234
+ options: { targetName: string; bundleId: string; deploymentTarget?: string; developmentTeam?: string; marketingVersion?: string; buildNumber?: string },
235
+ ): { added: boolean } {
236
+ const { targetName } = options;
237
+ if (project.pbxTargetByName(targetName) || project.pbxTargetByName(`"${targetName}"`)) return { added: false };
238
+
239
+ const objects = project.hash.project.objects;
240
+ // The xcode package assumes these sections exist; Expo's template has none.
241
+ objects.PBXTargetDependency ??= {};
242
+ objects.PBXContainerItemProxy ??= {};
243
+
244
+ const deploymentTarget = options.deploymentTarget ?? appSetting(project, "IPHONEOS_DEPLOYMENT_TARGET") ?? "15.1";
245
+ const marketingVersion = options.marketingVersion ?? appSetting(project, "MARKETING_VERSION") ?? "1.0";
246
+ const buildNumber = options.buildNumber ?? appSetting(project, "CURRENT_PROJECT_VERSION") ?? "1";
247
+ const developmentTeam = options.developmentTeam ?? appSetting(project, "DEVELOPMENT_TEAM");
248
+
249
+ // The Swift is compiled; the Info.plist and entitlements are build settings, so they are in the group only.
250
+ const group = project.addPbxGroup([SWIFT_FILE], targetName, targetName);
251
+ project.addFile("Info.plist", group.uuid, { lastKnownFileType: "text.plist.xml" });
252
+ project.addFile(`${targetName}.entitlements`, group.uuid, { lastKnownFileType: "text.plist.entitlements" });
253
+ const mainGroup = project.getFirstProject().firstProject.mainGroup;
254
+ project.addToPbxGroup(group.uuid, mainGroup);
255
+
256
+ const target = project.addTarget(targetName, "app_extension", targetName, options.bundleId);
257
+ project.addBuildPhase([SWIFT_FILE], "PBXSourcesBuildPhase", "Sources", target.uuid);
258
+ project.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", target.uuid);
259
+ project.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", target.uuid);
260
+
261
+ const configs = project.pbxXCBuildConfigurationSection();
262
+ const listId = target.pbxNativeTarget.buildConfigurationList;
263
+ for (const ref of project.pbxXCConfigurationList()[listId].buildConfigurations) {
264
+ const settings = configs[ref.value].buildSettings;
265
+ Object.assign(settings, {
266
+ INFOPLIST_FILE: `${targetName}/Info.plist`,
267
+ CODE_SIGN_ENTITLEMENTS: `${targetName}/${targetName}.entitlements`,
268
+ CODE_SIGN_STYLE: "Automatic",
269
+ IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
270
+ TARGETED_DEVICE_FAMILY: '"1,2"',
271
+ SWIFT_VERSION: "5.0",
272
+ MARKETING_VERSION: marketingVersion,
273
+ CURRENT_PROJECT_VERSION: buildNumber,
274
+ GENERATE_INFOPLIST_FILE: "NO",
275
+ PRODUCT_BUNDLE_IDENTIFIER: `"${options.bundleId}"`,
276
+ SDKROOT: "iphoneos",
277
+ CLANG_ENABLE_MODULES: "YES",
278
+ SWIFT_EMIT_LOC_STRINGS: "YES",
279
+ ...(developmentTeam ? { DEVELOPMENT_TEAM: developmentTeam } : {}),
280
+ });
281
+ }
282
+ if (developmentTeam) project.addTargetAttribute("DevelopmentTeam", developmentTeam, target);
283
+ return { added: true };
284
+ }
285
+
286
+ const withRichPush: ConfigPlugin<RichPushPluginOptions | void> = (config, options) => {
287
+ const o = resolve(options ?? undefined);
288
+ config = applyRichPushConfig(config, o);
289
+ const bundleId = extensionBundleId(config.ios!.bundleIdentifier!, o.bundleIdSuffix);
290
+
291
+ config = withDangerousMod(config, [
292
+ "ios",
293
+ (c) => {
294
+ writeExtensionFiles(c.modRequest.platformProjectRoot, { targetName: o.targetName, displayName: c.name, communication: o.communication, imageHosts: o.imageHosts });
295
+ return c;
296
+ },
297
+ ]);
298
+ config = withXcodeProject(config, (c) => {
299
+ addNotificationServiceTarget(c.modResults, {
300
+ targetName: o.targetName,
301
+ bundleId,
302
+ ...(o.deploymentTarget ? { deploymentTarget: o.deploymentTarget } : {}),
303
+ ...(c.ios?.appleTeamId ? { developmentTeam: c.ios.appleTeamId } : {}),
304
+ ...(c.version ? { marketingVersion: c.version } : {}),
305
+ ...(c.ios?.buildNumber ? { buildNumber: c.ios.buildNumber } : {}),
306
+ });
307
+ return c;
308
+ });
309
+ return config;
310
+ };
311
+
312
+ export default withRichPush;
@@ -31,8 +31,16 @@ export const DEFAULT_PREFERENCES: NotificationPreferences = {
31
31
  };
32
32
 
33
33
  export interface PushBackend {
34
- /** Keep this device's token against the signed-in person. */
35
- register(token: string, platform: string, appVersion: string | null): Promise<void>;
34
+ /**
35
+ * Keep this device's token against the signed-in person. `installationId`
36
+ * is this install's random id (kept on the phone across sign-ins): a
37
+ * backend that checks it moves a token another login holds only when the
38
+ * id matches (the same phone). `supabasePushBackend` (migration 0005)
39
+ * doesn't send it; genesis-notifications' backend does.
40
+ */
41
+ register(token: string, platform: string, appVersion: string | null, installationId?: string): Promise<void>;
42
+ /** Send `installationId` to `register` (the block makes and keeps one). Default false. */
43
+ readonly usesInstallationId?: boolean;
36
44
  /** Forget this device's token; works signed out (holding the token is the proof). */
37
45
  forget(token: string): Promise<void>;
38
46
  /** The signed-in person's preferences, or `null` before they have saved any. */
@@ -65,6 +65,20 @@ export interface PushChannel {
65
65
  importance?: ChannelImportance;
66
66
  }
67
67
 
68
+ /** A notification category and its buttons. */
69
+ export interface PushDeviceCategory {
70
+ identifier: string;
71
+ actions: readonly {
72
+ id: string;
73
+ title: string;
74
+ textInput?: { placeholder?: string; submitTitle?: string };
75
+ opensApp?: boolean;
76
+ destructive?: boolean;
77
+ requiresAuth?: boolean;
78
+ }[];
79
+ previewPlaceholder?: string;
80
+ }
81
+
68
82
  // A type, not an interface, so it fits the slots contract's Record constraint.
69
83
  export type PushSlots = {
70
84
  /** The soft pre-prompt, shown after `usePushPrompt().offer()`. Default `PushPromptSheet`. */
@@ -86,6 +100,15 @@ export interface PushOptions {
86
100
  categories?: readonly NotificationCategory[];
87
101
  /** More Android channels. A `default` channel is always made. */
88
102
  channels?: readonly PushChannel[];
103
+ /**
104
+ * Notification categories with action buttons (Reply, Mark as read),
105
+ * registered on the device when the block starts. A push names one in its
106
+ * `categoryId`; a tap on a button reaches `onResponse` (`notificationAction`
107
+ * reads it). genesis-notifications' registry makes these (`deviceCategories()`).
108
+ */
109
+ deviceCategories?: readonly PushDeviceCategory[];
110
+ /** Makes this install's id, for a backend that uses one (`usesInstallationId`). Default `crypto.randomUUID`; pass expo-crypto's on Hermes. */
111
+ randomUUID?: () => string;
89
112
  /** AsyncStorage: this device's token (to forget it after sign-out), the prompt's "Not now"s. */
90
113
  storage?: PushStorageLike;
91
114
  /** `AppState`: read the permission again on coming to the front (the person may have changed it in Settings). */
@@ -195,6 +218,31 @@ export function createPush(options: PushOptions): PushBlock {
195
218
  return channelsMade;
196
219
  };
197
220
 
221
+ let categoriesMade = false;
222
+ const registerDeviceCategories = async () => {
223
+ const set = notifications?.setNotificationCategoryAsync;
224
+ if (categoriesMade || !notifications || !set || !options.deviceCategories?.length) return;
225
+ categoriesMade = true;
226
+ for (const c of options.deviceCategories) {
227
+ try {
228
+ await set.call(
229
+ notifications,
230
+ c.identifier,
231
+ c.actions.map((a) => ({
232
+ identifier: a.id,
233
+ buttonTitle: a.title,
234
+ ...(a.textInput ? { textInput: { placeholder: a.textInput.placeholder ?? "", submitButtonTitle: a.textInput.submitTitle ?? a.title } } : {}),
235
+ options: { opensAppToForeground: a.opensApp ?? false, isDestructive: a.destructive ?? false, isAuthenticationRequired: a.requiresAuth ?? false },
236
+ })),
237
+ c.previewPlaceholder ? { previewPlaceholder: c.previewPlaceholder } : undefined,
238
+ );
239
+ } catch (error) {
240
+ categoriesMade = false;
241
+ report(onError, error, "categories");
242
+ }
243
+ }
244
+ };
245
+
198
246
  const permission = createPermissionStore({ notifications, request: options.permissions, beforeRequest: ensureChannels, onError });
199
247
  const registration = createRegistration({
200
248
  notifications,
@@ -207,6 +255,7 @@ export function createPush(options: PushOptions): PushBlock {
207
255
  ensureChannels,
208
256
  isCurrent: (id) => userId === id,
209
257
  now,
258
+ randomUUID: options.randomUUID,
210
259
  onError,
211
260
  });
212
261
  const preferences = createPreferencesStore({ backend: options.backend, categories, onError });
@@ -334,9 +383,19 @@ export function createPush(options: PushOptions): PushBlock {
334
383
  });
335
384
  void registration.flush();
336
385
  void permission.refresh();
337
- const tokenSubscription = notifications.addPushTokenListener(() => {
386
+ void registerDeviceCategories();
387
+ // Fetching the Expo token fetches the device token, and expo-notifications tells this listener
388
+ // every time, changed or not: forcing a registration on each one re-registered in a loop. The
389
+ // same token again is ignored; the first one seen only confirms the registration (which
390
+ // registers when the token differs from the saved one); a different one is a rotation.
391
+ let deviceToken: unknown;
392
+ const tokenSubscription = notifications.addPushTokenListener((token) => {
393
+ const next = (token as { data?: unknown } | null)?.data;
394
+ const seen = deviceToken;
395
+ deviceToken = next;
396
+ if (!userId || next === seen) return;
338
397
  // The OS rotated the device token: fetch the Expo token again and register it.
339
- if (userId) void registration.sync(userId, { force: true });
398
+ void registration.sync(userId, { force: seen !== undefined });
340
399
  });
341
400
  const appSubscription = options.appState?.addEventListener("change", (state) => {
342
401
  if (state !== "active") return;
package/src/push/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  useSuppressNotifications,
29
29
  type PushBlock,
30
30
  type PushChannel,
31
+ type PushDeviceCategory,
31
32
  type PushOptions,
32
33
  type PushRootProps,
33
34
  type PushSlots,
@@ -84,12 +85,14 @@ export {
84
85
  type PreferencesStatus,
85
86
  type PreferencesStore,
86
87
  } from "./preferences";
87
- export { pathForResponse, notificationData } from "./routing";
88
+ export { richPushMessage, isRichPushUrl, RICH_PUSH_IMAGE_FIELDS, type RichPushData, type RichPushMessageLike } from "./payload";
89
+ export { pathForResponse, notificationData, notificationAction, type NotificationAction } from "./routing";
88
90
  export { parseClock, formatClock, clockOf, dateAt, deviceTimeZone, minutesIn, isQuietAt } from "./quiet-hours";
89
91
  export type {
90
92
  ChannelInputLike,
91
93
  NotificationBehaviorLike,
92
94
  NotificationLike,
95
+ NotificationActionLike,
93
96
  NotificationResponseLike,
94
97
  NotificationsModuleLike,
95
98
  PermissionRequestLike,
@@ -37,6 +37,16 @@ export interface NotificationLike {
37
37
  export interface NotificationResponseLike {
38
38
  notification: NotificationLike;
39
39
  actionIdentifier: string;
40
+ /** What the person typed, for an action with a text input (Reply). */
41
+ userText?: string;
42
+ }
43
+
44
+ /** `NotificationAction` (expo-notifications 58): one button of a notification category. */
45
+ export interface NotificationActionLike {
46
+ identifier: string;
47
+ buttonTitle: string;
48
+ textInput?: { placeholder: string; submitButtonTitle: string };
49
+ options?: { isAuthenticationRequired?: boolean; isDestructive?: boolean; opensAppToForeground?: boolean };
40
50
  }
41
51
 
42
52
  /** `NotificationBehavior` without the deprecated `shouldShowAlert`. */
@@ -73,6 +83,8 @@ export interface NotificationsModuleLike {
73
83
  getLastNotificationResponse(): NotificationResponseLike | null;
74
84
  clearLastNotificationResponse(): void;
75
85
  setBadgeCountAsync(count: number): Promise<boolean>;
86
+ /** Registers a notification category (its action buttons). Optional: older fakes leave it out. */
87
+ setNotificationCategoryAsync?(identifier: string, actions: NotificationActionLike[], options?: { previewPlaceholder?: string }): Promise<unknown>;
76
88
  getBadgeCountAsync(): Promise<number>;
77
89
  /** `"expo.modules.notifications.actions.DEFAULT"`: the person tapped the notification itself. */
78
90
  DEFAULT_ACTION_IDENTIFIER: string;
@@ -91,7 +103,7 @@ export interface PushAppStateLike {
91
103
  }
92
104
 
93
105
  /** Where a reported error came from. */
94
- export type PushErrorSource = "permission" | "register" | "forget" | "preferences" | "badge" | "channels" | "routing" | "storage" | "setup";
106
+ export type PushErrorSource = "permission" | "register" | "forget" | "preferences" | "badge" | "channels" | "categories" | "routing" | "storage" | "setup";
95
107
 
96
108
  export type PushOnError = (error: Error, source: PushErrorSource) => void;
97
109
 
@@ -0,0 +1,61 @@
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
+
14
+ /** The image fields the extension reads from `data`. */
15
+ export interface RichPushData {
16
+ /** An https image to attach (a photo in the message). Anything but https is dropped. */
17
+ image?: string | null | undefined;
18
+ /** The sender's photo, https. On a `messages` push it is the sender's avatar; on any other it is attached. */
19
+ senderAvatar?: string | null | undefined;
20
+ }
21
+
22
+ /** The part of an Expo push message this touches. */
23
+ export interface RichPushMessageLike {
24
+ data?: Record<string, unknown> | undefined;
25
+ mutableContent?: boolean | undefined;
26
+ }
27
+
28
+ export const RICH_PUSH_IMAGE_FIELDS = ["image", "senderAvatar"] as const;
29
+
30
+ /**
31
+ * The extension downloads only https URLs (no credentials, at most 1024
32
+ * characters: APNs takes 4 KB in all); this is the same check. The plugin's
33
+ * `imageHosts`, when set, narrows it on the phone.
34
+ */
35
+ export function isRichPushUrl(value: unknown): value is string {
36
+ if (typeof value !== "string" || value.length > 1024) return false;
37
+ try {
38
+ const url = new URL(value);
39
+ return url.protocol === "https:" && url.hostname.length > 0 && !url.username && !url.password;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The message with `mutableContent: true` and the image fields in its `data`.
47
+ * A field given in `rich` replaces the one in `data`; one that isn't an https
48
+ * URL is left out (the extension would ignore it). `data`'s other fields are
49
+ * kept as they are.
50
+ */
51
+ export function richPushMessage<M extends RichPushMessageLike>(
52
+ message: M,
53
+ rich: RichPushData = {},
54
+ ): Omit<M, "mutableContent" | "data"> & { mutableContent: true; data: Record<string, unknown> } {
55
+ const data: Record<string, unknown> = { ...(message.data ?? {}) };
56
+ for (const key of RICH_PUSH_IMAGE_FIELDS) {
57
+ if (key in rich) data[key] = rich[key];
58
+ if (key in data && !isRichPushUrl(data[key])) delete data[key];
59
+ }
60
+ return { ...message, mutableContent: true, data };
61
+ }