@marktiderman/genesis-native 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +470 -10
- package/dist/block-Onqx7NiK.d.ts +223 -0
- package/dist/blocks-C2RDpvaN.d.ts +1595 -0
- package/dist/chunk-65P7RU4V.js +49 -0
- package/dist/chunk-65P7RU4V.js.map +1 -0
- package/dist/chunk-6BT4PTGH.js +37 -0
- package/dist/chunk-6BT4PTGH.js.map +1 -0
- package/dist/chunk-75YP72G6.js +374 -0
- package/dist/chunk-75YP72G6.js.map +1 -0
- package/dist/chunk-NGYNYJPL.js +90 -0
- package/dist/chunk-NGYNYJPL.js.map +1 -0
- package/dist/chunk-ONAXQHNG.js +23 -0
- package/dist/chunk-ONAXQHNG.js.map +1 -0
- package/dist/chunk-VKTIHNAV.js +95 -0
- package/dist/chunk-VKTIHNAV.js.map +1 -0
- package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
- package/dist/chunk-VTJDIKFC.js.map +1 -0
- package/dist/chunk-XE7LC7K6.js +29 -0
- package/dist/chunk-XE7LC7K6.js.map +1 -0
- package/dist/dev-warnings.d.ts +34 -0
- package/dist/dev-warnings.js +15 -0
- package/dist/dev-warnings.js.map +1 -0
- package/dist/devtools.d.ts +144 -0
- package/dist/devtools.js +390 -0
- package/dist/devtools.js.map +1 -0
- package/dist/index.d.ts +12 -368
- package/dist/index.js +88 -191
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +170 -0
- package/dist/observability.js +123 -0
- package/dist/observability.js.map +1 -0
- package/dist/persist.d.ts +1 -1
- package/dist/persist.js +1 -1
- package/dist/push-payload.d.ts +43 -0
- package/dist/push-payload.js +3 -0
- package/dist/push-payload.js.map +1 -0
- package/dist/push.d.ts +187 -0
- package/dist/push.js +1069 -0
- package/dist/push.js.map +1 -0
- package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
- package/dist/rich-push-plugin.d.ts +74 -0
- package/dist/rich-push-plugin.js +209 -0
- package/dist/rich-push-plugin.js.map +1 -0
- package/dist/updates.d.ts +8 -1
- package/dist/updates.js +44 -69
- package/dist/updates.js.map +1 -1
- package/ios/notification-service/NotificationService.swift +309 -0
- package/package.json +238 -5
- package/src/app.tsx +491 -0
- package/src/auth.tsx +176 -0
- package/src/blocks.tsx +144 -0
- package/src/boot.tsx +188 -0
- package/src/dev-warnings.ts +43 -0
- package/src/devtools/block.tsx +488 -0
- package/src/devtools/gestures.ts +107 -0
- package/src/devtools/index.ts +22 -0
- package/src/devtools/info.ts +45 -0
- package/src/devtools/slots.ts +21 -0
- package/src/errors.tsx +195 -0
- package/src/held-link.ts +90 -0
- package/src/hydration-gate.ts +88 -0
- package/src/index.ts +83 -0
- package/src/links.ts +140 -0
- package/src/nativewind.d.ts +41 -0
- package/src/observability/block.tsx +300 -0
- package/src/observability/events.ts +89 -0
- package/src/observability/index.ts +44 -0
- package/src/observability/scrub.ts +166 -0
- package/src/observability/sentry.ts +222 -0
- package/src/observability/sink.ts +147 -0
- package/src/persist.ts +83 -0
- package/src/plugin/rich-push.ts +312 -0
- package/src/push/backend.ts +137 -0
- package/src/push/block.tsx +604 -0
- package/src/push/index.ts +105 -0
- package/src/push/modules.ts +123 -0
- package/src/push/payload.ts +61 -0
- package/src/push/permission.ts +191 -0
- package/src/push/preferences.ts +136 -0
- package/src/push/quiet-hours.ts +71 -0
- package/src/push/registration.ts +225 -0
- package/src/push/routing.ts +57 -0
- package/src/push/screens.tsx +46 -0
- package/src/push/settings.tsx +378 -0
- package/src/query.ts +100 -0
- package/src/updates/block.tsx +442 -0
- package/src/updates/channels.ts +131 -0
- package/src/updates/checker.ts +190 -0
- package/src/updates/index.ts +87 -0
- package/src/updates/modules.ts +64 -0
- package/src/updates/policy.ts +314 -0
- package/src/updates/screens.tsx +159 -0
- package/src/updates/signature.ts +80 -0
- package/src/updates/store.ts +33 -0
- package/src/updates/version.ts +39 -0
- package/src/updates/whats-new.ts +122 -0
- package/dist/block-Dh5ny06W.d.ts +0 -535
- package/dist/chunk-MMTPWUWI.js.map +0 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This device's Expo push token, kept against whoever is signed in.
|
|
3
|
+
*
|
|
4
|
+
* sync(userId) permission granted → (Android) channels → token → backend.register,
|
|
5
|
+
* again when the token changes and at most hourly otherwise (last seen)
|
|
6
|
+
* forget() backend.forget(token) on sign-out, an account switch or deletion;
|
|
7
|
+
* a forget that fails offline is kept and retried at the next start
|
|
8
|
+
*
|
|
9
|
+
* The token is remembered on the phone (storage) with the user it was
|
|
10
|
+
* registered for, so it can be forgotten after the session has ended. Every
|
|
11
|
+
* step runs in order on one queue, so a sign-out never races a registration.
|
|
12
|
+
*/
|
|
13
|
+
import { createStore, type Store } from "../updates/store";
|
|
14
|
+
import type { PushBackend } from "./backend";
|
|
15
|
+
import { report, type NotificationsModuleLike, type PushOnError, type PushStorageLike } from "./modules";
|
|
16
|
+
|
|
17
|
+
export type RegistrationStatus = "idle" | "registering" | "registered" | "unavailable" | "error";
|
|
18
|
+
|
|
19
|
+
export interface RegistrationState {
|
|
20
|
+
status: RegistrationStatus;
|
|
21
|
+
/**
|
|
22
|
+
* Why it isn't registered: `no_project_id` (the build has no EAS project id, so there is no
|
|
23
|
+
* Expo push token), `no_token` (the OS gave none: a simulator without push, or no network),
|
|
24
|
+
* `backend` (the server refused or couldn't be reached).
|
|
25
|
+
*/
|
|
26
|
+
problem: "no_project_id" | "no_token" | "backend" | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Registration extends Store<RegistrationState> {
|
|
30
|
+
sync(userId: string, options?: { force?: boolean }): Promise<void>;
|
|
31
|
+
forget(): Promise<void>;
|
|
32
|
+
/** Retry forgets that failed earlier (offline sign-out). */
|
|
33
|
+
flush(): Promise<void>;
|
|
34
|
+
/** This device's token, once known (the person's own; never shown in logs). */
|
|
35
|
+
token(): string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Saved {
|
|
39
|
+
token: string;
|
|
40
|
+
userId: string;
|
|
41
|
+
at: number;
|
|
42
|
+
}
|
|
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
|
+
|
|
62
|
+
/** Registered again at most this often while nothing changed (last seen, and a token another login took comes back). */
|
|
63
|
+
const REFRESH_MS = 60 * 60_000;
|
|
64
|
+
|
|
65
|
+
export function createRegistration(options: {
|
|
66
|
+
notifications?: NotificationsModuleLike;
|
|
67
|
+
backend: PushBackend;
|
|
68
|
+
projectId?: string | null;
|
|
69
|
+
platform: string;
|
|
70
|
+
appVersion?: string | null;
|
|
71
|
+
storage?: PushStorageLike;
|
|
72
|
+
storageKeyPrefix: string;
|
|
73
|
+
/** Creates the Android channels; runs before the token is asked for. */
|
|
74
|
+
ensureChannels: () => Promise<void>;
|
|
75
|
+
/** Is this user still the one signed in? A sync for someone who has signed out is dropped. */
|
|
76
|
+
isCurrent: (userId: string) => boolean;
|
|
77
|
+
now: () => number;
|
|
78
|
+
/** Makes this install's id (once; it is kept). Default `crypto.randomUUID`. */
|
|
79
|
+
randomUUID?: () => string;
|
|
80
|
+
onError?: PushOnError;
|
|
81
|
+
}): Registration {
|
|
82
|
+
const { notifications, backend, storage, storageKeyPrefix, onError, now } = options;
|
|
83
|
+
const savedKey = `${storageKeyPrefix}:device`;
|
|
84
|
+
const pendingKey = `${storageKeyPrefix}:forget`;
|
|
85
|
+
const installationKey = `${storageKeyPrefix}:installation`;
|
|
86
|
+
let installation: string | null = null;
|
|
87
|
+
const store = createStore<RegistrationState>({ status: "idle", problem: null });
|
|
88
|
+
let current: string | null = null;
|
|
89
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
90
|
+
let warnedNoProject = false;
|
|
91
|
+
|
|
92
|
+
const run = <T>(step: () => Promise<T>): Promise<T> => {
|
|
93
|
+
const next = queue.then(step, step);
|
|
94
|
+
queue = next.catch(() => {});
|
|
95
|
+
return next;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const read = async <T>(key: string, fallback: T): Promise<T> => {
|
|
99
|
+
if (!storage) return fallback;
|
|
100
|
+
try {
|
|
101
|
+
const raw = await storage.getItem(key);
|
|
102
|
+
return raw ? (JSON.parse(raw) as T) : fallback;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
report(onError, error, "storage");
|
|
105
|
+
return fallback;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const write = async (key: string, value: unknown) => {
|
|
109
|
+
try {
|
|
110
|
+
if (value === null) await storage?.removeItem?.(key);
|
|
111
|
+
else await storage?.setItem(key, JSON.stringify(value));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
report(onError, error, "storage");
|
|
114
|
+
}
|
|
115
|
+
};
|
|
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
|
+
|
|
129
|
+
const forgetToken = async (token: string): Promise<boolean> => {
|
|
130
|
+
try {
|
|
131
|
+
await backend.forget(token);
|
|
132
|
+
return true;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
report(onError, error, "forget");
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const queuePending = async (token: string) => {
|
|
140
|
+
const pending = await read<string[]>(pendingKey, []);
|
|
141
|
+
if (!pending.includes(token)) await write(pendingKey, [...pending, token].slice(-10));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
...store,
|
|
146
|
+
token: () => current,
|
|
147
|
+
sync(userId, { force = false } = {}) {
|
|
148
|
+
return run(async () => {
|
|
149
|
+
if (!notifications || !options.isCurrent(userId)) return;
|
|
150
|
+
if (!options.projectId) {
|
|
151
|
+
store.setState({ status: "unavailable", problem: "no_project_id" });
|
|
152
|
+
if (!warnedNoProject) {
|
|
153
|
+
warnedNoProject = true;
|
|
154
|
+
// Loud once: without an EAS project id there is no Expo push token, so no push reaches this build.
|
|
155
|
+
report(onError, new Error("push: no EAS project id (projectId), so this build gets no push token. Run `eas init` and pass Constants.expoConfig.extra.eas.projectId."), "setup");
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
store.setState({ status: "registering", problem: null });
|
|
160
|
+
await options.ensureChannels().catch((error) => report(onError, error, "channels"));
|
|
161
|
+
let token: string;
|
|
162
|
+
try {
|
|
163
|
+
token = (await notifications.getExpoPushTokenAsync({ projectId: options.projectId })).data;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
report(onError, error, "register");
|
|
166
|
+
store.setState({ status: "error", problem: "no_token" });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
170
|
+
store.setState({ status: "error", problem: "no_token" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
current = token;
|
|
174
|
+
const saved = await read<Saved | null>(savedKey, null);
|
|
175
|
+
// A different token kept for someone else on this phone: forget it. (The same token the server moves itself.)
|
|
176
|
+
if (saved && saved.userId !== userId && saved.token !== token && !(await forgetToken(saved.token))) await queuePending(saved.token);
|
|
177
|
+
if (!force && saved && saved.token === token && saved.userId === userId && now() - saved.at < REFRESH_MS) {
|
|
178
|
+
store.setState({ status: "registered", problem: null });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!options.isCurrent(userId)) return;
|
|
182
|
+
try {
|
|
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);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
report(onError, error, "register");
|
|
188
|
+
store.setState({ status: "error", problem: "backend" });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// An older token this phone had (the OS rotated it) goes; the new one stays.
|
|
192
|
+
if (saved && saved.token !== token && saved.userId === userId && !(await forgetToken(saved.token))) await queuePending(saved.token);
|
|
193
|
+
await write(savedKey, { token, userId, at: now() } satisfies Saved);
|
|
194
|
+
// A forget of this same token still waiting from an offline sign-out would undo this registration.
|
|
195
|
+
const pending = await read<string[]>(pendingKey, []);
|
|
196
|
+
if (pending.includes(token)) await write(pendingKey, pending.filter((t) => t !== token).length ? pending.filter((t) => t !== token) : null);
|
|
197
|
+
store.setState({ status: "registered", problem: null });
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
forget() {
|
|
201
|
+
return run(async () => {
|
|
202
|
+
const saved = await read<Saved | null>(savedKey, null);
|
|
203
|
+
const token = saved?.token ?? current;
|
|
204
|
+
await write(savedKey, null);
|
|
205
|
+
store.setState({ status: "idle", problem: null });
|
|
206
|
+
if (!token) return;
|
|
207
|
+
if (!(await forgetToken(token))) await queuePending(token);
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
flush() {
|
|
211
|
+
return run(async () => {
|
|
212
|
+
const pending = await read<string[]>(pendingKey, []);
|
|
213
|
+
if (pending.length === 0) return;
|
|
214
|
+
const left: string[] = [];
|
|
215
|
+
const registered = (await read<Saved | null>(savedKey, null))?.token;
|
|
216
|
+
for (const token of pending) {
|
|
217
|
+
// Registered again since (signed back in): keep it.
|
|
218
|
+
if (token === registered) continue;
|
|
219
|
+
if (!(await forgetToken(token))) left.push(token);
|
|
220
|
+
}
|
|
221
|
+
await write(pendingKey, left.length ? left : null);
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a tapped notification goes. The sender puts an app path in
|
|
3
|
+
* `data.url` (`/messages/42`); it goes through the same `normalizeLink` as
|
|
4
|
+
* every deep link, so a path the app doesn't know opens `/` and an ignored
|
|
5
|
+
* one goes nowhere. Without link options, only a plain app path is followed.
|
|
6
|
+
*/
|
|
7
|
+
import { normalizeLink, type LinkOptions } from "../links";
|
|
8
|
+
import type { NotificationLike, NotificationResponseLike } from "./modules";
|
|
9
|
+
|
|
10
|
+
const ANY_PATH: LinkOptions = { map: { "/*": "/*" } };
|
|
11
|
+
|
|
12
|
+
/** The data a notification carries (`content.data`), or `{}`. */
|
|
13
|
+
export function notificationData(notification: NotificationLike): Record<string, unknown> {
|
|
14
|
+
const data = notification.request?.content?.data;
|
|
15
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The app path a tap should open, or null. */
|
|
19
|
+
export function pathForResponse(response: NotificationResponseLike, links?: LinkOptions): string | null {
|
|
20
|
+
const url = notificationData(response.notification).url;
|
|
21
|
+
if (typeof url !== "string" || url.length === 0 || url.length > 2048) return null;
|
|
22
|
+
if (links) return normalizeLink(url, links);
|
|
23
|
+
// No link map: an app path only (never another app's URL), with "." and ".." refused as normalizeLink does.
|
|
24
|
+
if (!url.startsWith("/") || url.startsWith("//") || url.includes("\\")) return null;
|
|
25
|
+
const path = normalizeLink(url, ANY_PATH);
|
|
26
|
+
return path === "/" && url.split(/[?#]/)[0] !== "/" ? null : path;
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The soft pre-prompt: the default of the `pushPrompt` slot, built from
|
|
3
|
+
* `@marktiderman/genesis-ui-native`. It says what push is for before the
|
|
4
|
+
* system asks, so a person who isn't ready says "Not now" here, where the
|
|
5
|
+
* app can ask again later, rather than "Don't Allow" there, where it can't.
|
|
6
|
+
*/
|
|
7
|
+
import { View } from "react-native";
|
|
8
|
+
import { NativeButton, NativeSheet, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
9
|
+
|
|
10
|
+
export interface PushPromptProps {
|
|
11
|
+
/** What `offer(reason)` said push is for here ("to hear when someone replies"), or null. */
|
|
12
|
+
reason: string | null;
|
|
13
|
+
/** "Turn on": shows the system prompt. */
|
|
14
|
+
accept: () => void;
|
|
15
|
+
/** "Not now": asks again after the cooldown, never more than `maxDeclines` times. */
|
|
16
|
+
decline: () => void;
|
|
17
|
+
title?: string;
|
|
18
|
+
body?: string;
|
|
19
|
+
acceptLabel?: string;
|
|
20
|
+
declineLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The sheet is iOS's full-height page sheet: grow to fill it and centre the few lines in it, rather
|
|
24
|
+
// than leave them at the top of an empty sheet. On Android the panel sizes to its content.
|
|
25
|
+
const PROMPT_CONTENT = { flexGrow: 1, justifyContent: "center", gap: 16, padding: 24 } as const;
|
|
26
|
+
|
|
27
|
+
export function PushPromptSheet({ reason, accept, decline, title, body, acceptLabel, declineLabel }: PushPromptProps) {
|
|
28
|
+
return (
|
|
29
|
+
<NativeSheet open onOpenChange={(open) => (open ? undefined : decline())} testID="genesis-push-prompt">
|
|
30
|
+
<View style={PROMPT_CONTENT} testID="genesis-push-prompt-content">
|
|
31
|
+
<NativeText preset="h3" accessibilityRole="header">
|
|
32
|
+
{title ?? "Turn on notifications?"}
|
|
33
|
+
</NativeText>
|
|
34
|
+
<NativeText preset="body" className="text-muted-foreground" testID="genesis-push-prompt-body">
|
|
35
|
+
{body ?? (reason ? `Get a notification ${reason}. You can change this any time in Settings.` : "Get a notification when something needs you. You can change this any time in Settings.")}
|
|
36
|
+
</NativeText>
|
|
37
|
+
<NativeButton onPress={accept} testID="genesis-push-prompt-accept">
|
|
38
|
+
{acceptLabel ?? "Turn on"}
|
|
39
|
+
</NativeButton>
|
|
40
|
+
<NativeButton variant="ghost" onPress={decline} testID="genesis-push-prompt-decline">
|
|
41
|
+
{declineLabel ?? "Not now"}
|
|
42
|
+
</NativeButton>
|
|
43
|
+
</View>
|
|
44
|
+
</NativeSheet>
|
|
45
|
+
);
|
|
46
|
+
}
|