@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.
Files changed (98) hide show
  1. package/README.md +470 -10
  2. package/dist/block-Onqx7NiK.d.ts +223 -0
  3. package/dist/blocks-C2RDpvaN.d.ts +1595 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-ONAXQHNG.js +23 -0
  13. package/dist/chunk-ONAXQHNG.js.map +1 -0
  14. package/dist/chunk-VKTIHNAV.js +95 -0
  15. package/dist/chunk-VKTIHNAV.js.map +1 -0
  16. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  17. package/dist/chunk-VTJDIKFC.js.map +1 -0
  18. package/dist/chunk-XE7LC7K6.js +29 -0
  19. package/dist/chunk-XE7LC7K6.js.map +1 -0
  20. package/dist/dev-warnings.d.ts +34 -0
  21. package/dist/dev-warnings.js +15 -0
  22. package/dist/dev-warnings.js.map +1 -0
  23. package/dist/devtools.d.ts +144 -0
  24. package/dist/devtools.js +390 -0
  25. package/dist/devtools.js.map +1 -0
  26. package/dist/index.d.ts +12 -368
  27. package/dist/index.js +88 -191
  28. package/dist/index.js.map +1 -1
  29. package/dist/observability.d.ts +170 -0
  30. package/dist/observability.js +123 -0
  31. package/dist/observability.js.map +1 -0
  32. package/dist/persist.d.ts +1 -1
  33. package/dist/persist.js +1 -1
  34. package/dist/push-payload.d.ts +43 -0
  35. package/dist/push-payload.js +3 -0
  36. package/dist/push-payload.js.map +1 -0
  37. package/dist/push.d.ts +187 -0
  38. package/dist/push.js +1069 -0
  39. package/dist/push.js.map +1 -0
  40. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  41. package/dist/rich-push-plugin.d.ts +74 -0
  42. package/dist/rich-push-plugin.js +209 -0
  43. package/dist/rich-push-plugin.js.map +1 -0
  44. package/dist/updates.d.ts +8 -1
  45. package/dist/updates.js +44 -69
  46. package/dist/updates.js.map +1 -1
  47. package/ios/notification-service/NotificationService.swift +309 -0
  48. package/package.json +238 -5
  49. package/src/app.tsx +491 -0
  50. package/src/auth.tsx +176 -0
  51. package/src/blocks.tsx +144 -0
  52. package/src/boot.tsx +188 -0
  53. package/src/dev-warnings.ts +43 -0
  54. package/src/devtools/block.tsx +488 -0
  55. package/src/devtools/gestures.ts +107 -0
  56. package/src/devtools/index.ts +22 -0
  57. package/src/devtools/info.ts +45 -0
  58. package/src/devtools/slots.ts +21 -0
  59. package/src/errors.tsx +195 -0
  60. package/src/held-link.ts +90 -0
  61. package/src/hydration-gate.ts +88 -0
  62. package/src/index.ts +83 -0
  63. package/src/links.ts +140 -0
  64. package/src/nativewind.d.ts +41 -0
  65. package/src/observability/block.tsx +300 -0
  66. package/src/observability/events.ts +89 -0
  67. package/src/observability/index.ts +44 -0
  68. package/src/observability/scrub.ts +166 -0
  69. package/src/observability/sentry.ts +222 -0
  70. package/src/observability/sink.ts +147 -0
  71. package/src/persist.ts +83 -0
  72. package/src/plugin/rich-push.ts +312 -0
  73. package/src/push/backend.ts +137 -0
  74. package/src/push/block.tsx +604 -0
  75. package/src/push/index.ts +105 -0
  76. package/src/push/modules.ts +123 -0
  77. package/src/push/payload.ts +61 -0
  78. package/src/push/permission.ts +191 -0
  79. package/src/push/preferences.ts +136 -0
  80. package/src/push/quiet-hours.ts +71 -0
  81. package/src/push/registration.ts +225 -0
  82. package/src/push/routing.ts +57 -0
  83. package/src/push/screens.tsx +46 -0
  84. package/src/push/settings.tsx +378 -0
  85. package/src/query.ts +100 -0
  86. package/src/updates/block.tsx +442 -0
  87. package/src/updates/channels.ts +131 -0
  88. package/src/updates/checker.ts +190 -0
  89. package/src/updates/index.ts +87 -0
  90. package/src/updates/modules.ts +64 -0
  91. package/src/updates/policy.ts +314 -0
  92. package/src/updates/screens.tsx +159 -0
  93. package/src/updates/signature.ts +80 -0
  94. package/src/updates/store.ts +33 -0
  95. package/src/updates/version.ts +39 -0
  96. package/src/updates/whats-new.ts +122 -0
  97. package/dist/block-Dh5ny06W.d.ts +0 -535
  98. package/dist/chunk-MMTPWUWI.js.map +0 -1
@@ -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;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Where the push block keeps devices and preferences: migration 0005 of
3
+ * @marktiderman/genesis-supabase, through the app's Supabase client and row
4
+ * level security. `supabasePushBackend(supabase)` is the one to use; tests
5
+ * (and an app with its own server) pass their own `PushBackend`.
6
+ */
7
+
8
+ /** A person's notification preferences, as the screens edit them. */
9
+ export interface NotificationPreferences {
10
+ /** Category id → on or off. A category not listed has its default (`NotificationCategory.defaultOn`). */
11
+ categories: Record<string, boolean>;
12
+ quietHours: QuietHours;
13
+ /** Hide what a push says on the lock screen: the sender sends neutral text instead. */
14
+ hidePreviews: boolean;
15
+ }
16
+
17
+ export interface QuietHours {
18
+ enabled: boolean;
19
+ /** Local time, `"HH:MM"` (24-hour). */
20
+ start: string;
21
+ /** Local time, `"HH:MM"`; before `start` means the quiet runs past midnight. */
22
+ end: string;
23
+ /** IANA time zone (`"Europe/London"`) the times are read in; required while enabled. */
24
+ timeZone: string | null;
25
+ }
26
+
27
+ export const DEFAULT_PREFERENCES: NotificationPreferences = {
28
+ categories: {},
29
+ quietHours: { enabled: false, start: "22:00", end: "07:00", timeZone: null },
30
+ hidePreviews: false,
31
+ };
32
+
33
+ export interface PushBackend {
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;
44
+ /** Forget this device's token; works signed out (holding the token is the proof). */
45
+ forget(token: string): Promise<void>;
46
+ /** The signed-in person's preferences, or `null` before they have saved any. */
47
+ loadPreferences(userId: string): Promise<NotificationPreferences | null>;
48
+ savePreferences(userId: string, preferences: NotificationPreferences): Promise<void>;
49
+ }
50
+
51
+ type Result = { data: unknown; error: { message?: string; code?: string | null } | null };
52
+
53
+ /** The parts of a Supabase client the backend uses; a `SupabaseClient` fits, typed by your Database or not. */
54
+ export interface PushSupabaseLike {
55
+ // Loose on purpose (as deleteMyAccount's): a client typed by an app's Database narrows `rpc`'s
56
+ // and `from`'s arguments to its own functions and tables, which these names may not be in yet.
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ rpc: (fn: any, ...rest: any[]) => PromiseLike<Result>;
59
+ from(table: string): unknown;
60
+ }
61
+
62
+ interface PreferencesQuery {
63
+ select(columns: string): { eq(column: string, value: string): { maybeSingle(): PromiseLike<Result> } };
64
+ upsert(row: Record<string, unknown>, options: { onConflict: string }): { select(columns: string): PromiseLike<Result> };
65
+ }
66
+
67
+ /** A database refusal, with its SQLSTATE; the message is the database's own, never a token. */
68
+ export class PushBackendError extends Error {
69
+ constructor(
70
+ readonly operation: string,
71
+ readonly code: string | null,
72
+ ) {
73
+ super(`${operation} failed${code ? ` (${code})` : ""}`);
74
+ this.name = "PushBackendError";
75
+ }
76
+ }
77
+
78
+ const COLUMNS = "categories, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, time_zone, hide_previews";
79
+
80
+ /** Postgres answers `time` as `"22:00:00"`. */
81
+ function clock(value: unknown, fallback: string): string {
82
+ const match = typeof value === "string" ? /^(\d{2}):(\d{2})/.exec(value) : null;
83
+ return match ? `${match[1]}:${match[2]}` : fallback;
84
+ }
85
+
86
+ function fromRow(row: Record<string, unknown>): NotificationPreferences {
87
+ const categories: Record<string, boolean> = {};
88
+ const raw = row.categories;
89
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
90
+ for (const [id, on] of Object.entries(raw as Record<string, unknown>)) if (typeof on === "boolean") categories[id] = on;
91
+ }
92
+ return {
93
+ categories,
94
+ quietHours: {
95
+ enabled: row.quiet_hours_enabled === true,
96
+ start: clock(row.quiet_hours_start, DEFAULT_PREFERENCES.quietHours.start),
97
+ end: clock(row.quiet_hours_end, DEFAULT_PREFERENCES.quietHours.end),
98
+ timeZone: typeof row.time_zone === "string" ? row.time_zone : null,
99
+ },
100
+ hidePreviews: row.hide_previews === true,
101
+ };
102
+ }
103
+
104
+ /** Migration 0005's functions and tables, under the signed-in person's row level security. */
105
+ export function supabasePushBackend(client: PushSupabaseLike): PushBackend {
106
+ const must = (operation: string, result: Result) => {
107
+ if (result.error) throw new PushBackendError(operation, result.error.code ?? null);
108
+ return result.data;
109
+ };
110
+ return {
111
+ async register(token, platform, appVersion) {
112
+ must("genesis_push_register", await client.rpc("genesis_push_register", { p_token: token, p_platform: platform, p_app_version: appVersion }));
113
+ },
114
+ async forget(token) {
115
+ must("genesis_push_forget", await client.rpc("genesis_push_forget", { p_token: token }));
116
+ },
117
+ async loadPreferences(userId) {
118
+ // Row level security already limits it to the caller; the filter says so twice.
119
+ const data = must("notification_preferences", await (client.from("notification_preferences") as PreferencesQuery).select(COLUMNS).eq("user_id", userId).maybeSingle());
120
+ return data && typeof data === "object" ? fromRow(data as Record<string, unknown>) : null;
121
+ },
122
+ async savePreferences(userId, p) {
123
+ const row = {
124
+ user_id: userId,
125
+ categories: p.categories,
126
+ quiet_hours_enabled: p.quietHours.enabled,
127
+ quiet_hours_start: p.quietHours.start,
128
+ quiet_hours_end: p.quietHours.end,
129
+ time_zone: p.quietHours.timeZone,
130
+ hide_previews: p.hidePreviews,
131
+ };
132
+ const data = must("notification_preferences", await (client.from("notification_preferences") as PreferencesQuery).upsert(row, { onConflict: "user_id" }).select("user_id"));
133
+ // Row level security turns a write it refuses into zero rows, not an error.
134
+ if (!Array.isArray(data) || data.length !== 1) throw new PushBackendError("notification_preferences", "no_rows");
135
+ },
136
+ };
137
+ }