@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.
@@ -41,6 +41,24 @@ interface Saved {
41
41
  at: number;
42
42
  }
43
43
 
44
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
45
+
46
+ /** A random v4 UUID from the given maker or WebCrypto; undefined when neither is there (no id is sent). */
47
+ function makeUUID(maker?: () => string): string | undefined {
48
+ try {
49
+ const made = maker?.() ?? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID?.();
50
+ if (typeof made === "string" && UUID.test(made)) return made.toLowerCase();
51
+ const bytes = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto?.getRandomValues?.(new Uint8Array(16));
52
+ if (!bytes) return undefined;
53
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
54
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
55
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
56
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
44
62
  /** Registered again at most this often while nothing changed (last seen, and a token another login took comes back). */
45
63
  const REFRESH_MS = 60 * 60_000;
46
64
 
@@ -57,11 +75,15 @@ export function createRegistration(options: {
57
75
  /** Is this user still the one signed in? A sync for someone who has signed out is dropped. */
58
76
  isCurrent: (userId: string) => boolean;
59
77
  now: () => number;
78
+ /** Makes this install's id (once; it is kept). Default `crypto.randomUUID`. */
79
+ randomUUID?: () => string;
60
80
  onError?: PushOnError;
61
81
  }): Registration {
62
82
  const { notifications, backend, storage, storageKeyPrefix, onError, now } = options;
63
83
  const savedKey = `${storageKeyPrefix}:device`;
64
84
  const pendingKey = `${storageKeyPrefix}:forget`;
85
+ const installationKey = `${storageKeyPrefix}:installation`;
86
+ let installation: string | null = null;
65
87
  const store = createStore<RegistrationState>({ status: "idle", problem: null });
66
88
  let current: string | null = null;
67
89
  let queue: Promise<unknown> = Promise.resolve();
@@ -92,6 +114,18 @@ export function createRegistration(options: {
92
114
  }
93
115
  };
94
116
 
117
+ /** This install's id: made once, kept across sign-ins (not cleared on sign-out). */
118
+ const installationId = async (): Promise<string | undefined> => {
119
+ if (installation) return installation;
120
+ const saved = await read<string | null>(installationKey, null);
121
+ if (typeof saved === "string" && UUID.test(saved)) return (installation = saved);
122
+ const made = makeUUID(options.randomUUID);
123
+ if (!made) return undefined;
124
+ installation = made;
125
+ await write(installationKey, made);
126
+ return made;
127
+ };
128
+
95
129
  const forgetToken = async (token: string): Promise<boolean> => {
96
130
  try {
97
131
  await backend.forget(token);
@@ -146,7 +180,9 @@ export function createRegistration(options: {
146
180
  }
147
181
  if (!options.isCurrent(userId)) return;
148
182
  try {
149
- await backend.register(token, options.platform === "ios" ? "ios" : "android", options.appVersion ?? null);
183
+ const platform = options.platform === "ios" ? "ios" : "android";
184
+ if (backend.usesInstallationId) await backend.register(token, platform, options.appVersion ?? null, await installationId());
185
+ else await backend.register(token, platform, options.appVersion ?? null);
150
186
  } catch (error) {
151
187
  report(onError, error, "register");
152
188
  store.setState({ status: "error", problem: "backend" });
@@ -25,3 +25,33 @@ export function pathForResponse(response: NotificationResponseLike, links?: Link
25
25
  const path = normalizeLink(url, ANY_PATH);
26
26
  return path === "/" && url.split(/[?#]/)[0] !== "/" ? null : path;
27
27
  }
28
+
29
+ /** A notification action (a button such as Reply or Mark as read), as the app handles it. */
30
+ export interface NotificationAction {
31
+ /** The button's id (`reply`, `mark_read`); the plain tap is not an action. */
32
+ actionId: string;
33
+ /** 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. */
34
+ notificationId: string | null;
35
+ /** What the person typed, for a text-input action. */
36
+ text: string | null;
37
+ /** `data.url`, the screen it belongs to (`/messages/<conversation>`). */
38
+ url: string | null;
39
+ data: Record<string, unknown>;
40
+ }
41
+
42
+ /**
43
+ * The action in a response, or null for a plain tap (`defaultActionId`,
44
+ * expo-notifications' `DEFAULT_ACTION_IDENTIFIER`) or a dismissal.
45
+ */
46
+ export function notificationAction(response: NotificationResponseLike, defaultActionId = "expo.modules.notifications.actions.DEFAULT"): NotificationAction | null {
47
+ const id = response.actionIdentifier;
48
+ if (!id || id === defaultActionId || id === "com.apple.UNNotificationDismissActionIdentifier") return null;
49
+ const data = notificationData(response.notification);
50
+ return {
51
+ actionId: id,
52
+ notificationId: typeof data.notification_id === "string" ? data.notification_id : null,
53
+ text: typeof response.userText === "string" ? response.userText : null,
54
+ url: typeof data.url === "string" ? data.url : null,
55
+ data,
56
+ };
57
+ }