@pithy-sh/ui-react-native 0.0.0-stage → 0.2.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/dist/generatedConfig.d.ts +195 -0
  3. package/dist/generatedConfig.d.ts.map +1 -0
  4. package/dist/generatedConfig.js +323 -0
  5. package/dist/templates.d.ts +48 -0
  6. package/dist/templates.d.ts.map +1 -0
  7. package/dist/templates.js +85 -0
  8. package/dist/version.d.ts +5 -0
  9. package/dist/version.d.ts.map +1 -0
  10. package/dist/version.js +10 -0
  11. package/package.json +83 -4
  12. package/src/generatedConfig.ts +419 -0
  13. package/src/templates.ts +92 -0
  14. package/src/version.ts +13 -0
  15. package/templates/app/[...pithy].tsx +7 -0
  16. package/templates/app/_layout.tsx +31 -0
  17. package/templates/app/index.tsx +4 -0
  18. package/templates/app.config.ts +56 -0
  19. package/templates/metro-proxy.cjs +289 -0
  20. package/templates/metro.config.js +6 -0
  21. package/templates/src/payments-store.native.ts +20 -0
  22. package/templates/src/payments-store.ts +16 -0
  23. package/templates/src/payments.tsx +769 -0
  24. package/templates/src/pithy-config.generated.ts +99 -0
  25. package/templates/src/pithy-config.ts +169 -0
  26. package/templates/src/pithy-locale.tsx +90 -0
  27. package/templates/src/platform.ts +111 -0
  28. package/templates/src/route-modules.ts +76 -0
  29. package/templates/src/router.tsx +387 -0
  30. package/templates/src/routes/app/home.bare.tsx +75 -0
  31. package/templates/src/routes/app/home.tsx +55 -0
  32. package/templates/src/routes/pithy/account-deleted.tsx +69 -0
  33. package/templates/src/routes/pithy/account-suspended.tsx +70 -0
  34. package/templates/src/routes/pithy/agent-consent.tsx +260 -0
  35. package/templates/src/routes/pithy/callback.tsx +74 -0
  36. package/templates/src/routes/pithy/choose-organization.tsx +230 -0
  37. package/templates/src/routes/pithy/connected-applications.tsx +153 -0
  38. package/templates/src/routes/pithy/consent.tsx +453 -0
  39. package/templates/src/routes/pithy/invitation.tsx +239 -0
  40. package/templates/src/routes/pithy/organization-suspended.tsx +51 -0
  41. package/templates/src/routes/pithy/otp.tsx +223 -0
  42. package/templates/src/routes/pithy/paywall.tsx +253 -0
  43. package/templates/src/routes/pithy/pricing.tsx +317 -0
  44. package/templates/src/routes/pithy/sign-in.tsx +674 -0
  45. package/templates/src/routes/pithy/subscription.tsx +240 -0
  46. package/templates/src/session.tsx +349 -0
  47. package/templates/src/theme.ts +100 -0
  48. package/templates/src/transport.ts +290 -0
  49. package/templates/src/turnstile.tsx +117 -0
  50. package/templates/src/useAppFocus.ts +20 -0
  51. package/templates/src/useStoreTransactions.ts +16 -0
  52. package/templates/src/useTurnstileWeb.ts +104 -0
  53. package/templates/tsconfig.json +10 -0
  54. package/README.md +0 -3
@@ -0,0 +1,240 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
3
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
4
+ import {
5
+ PAYMENTS_HOSTED_RAILS,
6
+ type PaymentsClientOptions,
7
+ type PaymentsClientRail,
8
+ } from "@pithy-sh/payments/src/client/api";
9
+ import { useSubscription } from "@pithy-sh/payments/src/client/hooks";
10
+ import type { PaymentsRailChoice } from "@pithy-sh/payments/src/client/railChoice";
11
+ import { type ReactNode, useMemo, useRef } from "react";
12
+ import { Pressable, Text, View } from "react-native";
13
+ import { failureText, returningClient } from "../../payments";
14
+ import { paymentsConfig, platformRails } from "../../pithy-config";
15
+ import { devicePlatform } from "../../platform";
16
+ import { Link, useScreenPath } from "../../router";
17
+ import { type Theme, useTheme } from "../../theme";
18
+ import { useAppFocus } from "../../useAppFocus";
19
+
20
+ export const path = "/subscription";
21
+
22
+ // What the paywall and the pricing screen link to as "what do I already have?". Rename `path` above and both
23
+ // follow, because they read this claim rather than keeping a copy of the string.
24
+ export const role = "subscription";
25
+
26
+ // The entitlements shown are the caller's own; the server resolves them from the session. Under a project that
27
+ // bills organizations they are the organization's, resolved from that same session. Either way this screen names no
28
+ // holder and cannot, which is what stops it becoming a way to read somebody else's.
29
+ export const session = "required";
30
+
31
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
32
+
33
+ /**
34
+ * This screen's English, baked in: the only catalog that survives being copied into your repository.
35
+ *
36
+ * The renewal date is not in here, and could not be: a date is `Intl`'s to render, in the reader's own locale,
37
+ * from the translator's `formattingLocale`. What the message carries is the sentence around it.
38
+ */
39
+ const EN = {
40
+ "payments/subscription.subscribed": "You're subscribed.",
41
+ "payments/subscription.empty": "Nothing yet.",
42
+ "payments/subscription.unreadable": "We couldn't check.",
43
+ "payments/subscription.nothing_held": "You don't hold anything on this account.",
44
+ "payments/subscription.loading": "One moment.",
45
+ "payments/subscription.holding.ended": "Ended.",
46
+ "payments/subscription.holding.ended_not_renewing": "Ended, and not renewing.",
47
+ "payments/subscription.holding.kept": "Yours to keep.",
48
+ "payments/subscription.holding.renews": "Renews {date}.",
49
+ "payments/subscription.manage": "Manage billing",
50
+ "payments/subscription.apple": "Bought on the App Store",
51
+ "payments/subscription.google": "Bought on Google Play",
52
+ "payments/subscription.more": "See what else there is",
53
+ } satisfies MessageCatalog;
54
+
55
+ /**
56
+ * What to say about one entitlement.
57
+ *
58
+ * `granted` comes first: the server applies the expiry itself on every read, so a row can come back
59
+ * `granted: false` while its own `expiresAt` still says nothing is wrong. A refunded non-consumable is
60
+ * `{ granted: false, expiresAt: null }`, and reading only the date would print "Yours to keep." over a purchase the
61
+ * holder no longer has.
62
+ */
63
+ function holding(t: Translator, entitlement: { granted: boolean; expiresAt: string | null }): string {
64
+ if (!entitlement.granted) {
65
+ return entitlement.expiresAt === null
66
+ ? t.t("payments/subscription.holding.ended")
67
+ : t.t("payments/subscription.holding.ended_not_renewing");
68
+ }
69
+ if (entitlement.expiresAt === null) return t.t("payments/subscription.holding.kept");
70
+ // The app's locale, not the device's: a reader who chose Spanish inside the app reads a Spanish date.
71
+ return t.t("payments/subscription.holding.renews", { date: t.formatDate(new Date(entitlement.expiresAt)) });
72
+ }
73
+
74
+ /** One of this screen's buttons. The first is filled with the accent; the rest are outlined. */
75
+ function Action(props: {
76
+ theme: Theme;
77
+ label: string;
78
+ primary?: boolean;
79
+ disabled?: boolean;
80
+ onPress: () => void;
81
+ }): ReactNode {
82
+ const { theme } = props;
83
+ return (
84
+ <Pressable
85
+ role="button"
86
+ disabled={props.disabled}
87
+ aria-disabled={props.disabled}
88
+ onPress={props.onPress}
89
+ style={{
90
+ borderWidth: 1,
91
+ borderColor: props.primary ? theme.colors.accent : theme.colors.border,
92
+ backgroundColor: props.primary ? theme.colors.accent : "transparent",
93
+ borderRadius: theme.radius,
94
+ paddingVertical: theme.spacing.sm,
95
+ paddingHorizontal: theme.spacing.md,
96
+ alignSelf: "flex-start",
97
+ opacity: props.disabled ? 0.5 : 1,
98
+ }}
99
+ >
100
+ <Text style={[theme.type.button, { color: props.primary ? theme.colors.onAccent : theme.colors.text }]}>
101
+ {props.label}
102
+ </Text>
103
+ </Pressable>
104
+ );
105
+ }
106
+
107
+ export interface SubscriptionScreenProps {
108
+ /**
109
+ * The translator this screen renders through. Absent, the screen reads the provider a `TranslatorProvider`
110
+ * mounted, and with no provider it reads {@link EN}.
111
+ */
112
+ readonly t?: Translator;
113
+ /**
114
+ * Which rails the Worker sells through: `paymentsConfig.rails`. Keyed by the rail union rather than by `string`,
115
+ * so a rail added to the package whose projection nobody widened stops compiling here.
116
+ */
117
+ readonly rails: Readonly<Record<PaymentsClientRail, boolean>>;
118
+ /**
119
+ * The rail this platform sells through, from `pithy-config.generated.ts`. `none` is a build that sells nothing:
120
+ * it shows what the account holds and offers no way to buy or manage a purchase.
121
+ */
122
+ readonly choice: PaymentsRailChoice;
123
+ /**
124
+ * Where the payments routes are, and the fetch to reach them with. Absent, the app's own, with the system
125
+ * browser's close reported back so the screen reads the subscription again.
126
+ */
127
+ readonly client?: PaymentsClientOptions;
128
+ /** Where "see what else there is" points: the path the paywall screen declares, read through its role. */
129
+ readonly paywallPath: string;
130
+ }
131
+
132
+ /**
133
+ * The subscription screen, taking its projection rather than reading it, so a test mounts it against any set of
134
+ * rails and looks for the buttons a reader would.
135
+ *
136
+ * Managing a subscription belongs to whoever sold it. A hosted rail's billing portal is a page the Worker mints,
137
+ * which opens in the system browser on a phone; Apple's and Google's are pages in their own stores. Coming back to
138
+ * the app reads the subscription again, so a cancellation shows the moment the reader returns.
139
+ */
140
+ export function SubscriptionScreen({
141
+ rails,
142
+ choice,
143
+ client,
144
+ paywallPath,
145
+ t: given,
146
+ }: SubscriptionScreenProps): ReactNode {
147
+ const refreshAfter = useRef<() => void>(() => undefined);
148
+ const returning = useMemo(() => returningClient(() => refreshAfter.current()), []);
149
+ const { entitlements, subscribed, loading, refresh, manage, manageStore, managing, failure, readFailure } =
150
+ useSubscription(client ?? returning);
151
+ refreshAfter.current = refresh;
152
+ useAppFocus(refresh);
153
+ // Called unconditionally, and chosen from afterwards: `given ?? useTranslator(EN)` would skip the hook whenever
154
+ // the prop is passed, which is a hook count that changes between renders.
155
+ const baked = useTranslator(EN);
156
+ const t = given ?? baked;
157
+ const theme = useTheme();
158
+ const muted = [theme.type.body, { color: theme.colors.muted }];
159
+
160
+ if (loading) {
161
+ return (
162
+ <View style={{ flex: 1, padding: theme.spacing.lg, backgroundColor: theme.colors.background }}>
163
+ <Text style={muted}>{t.t("payments/subscription.loading")}</Text>
164
+ </View>
165
+ );
166
+ }
167
+
168
+ const sells = choice !== "none";
169
+
170
+ return (
171
+ <View
172
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
173
+ >
174
+ {/* A read that failed is not an account that holds nothing. "Nothing yet." over an unreachable Worker tells a
175
+ paying subscriber they have no subscription, one tap from the paywall that would sell them a second. */}
176
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
177
+ {readFailure
178
+ ? t.t("payments/subscription.unreadable")
179
+ : subscribed
180
+ ? t.t("payments/subscription.subscribed")
181
+ : t.t("payments/subscription.empty")}
182
+ </Text>
183
+
184
+ {readFailure ? (
185
+ <Text style={muted}>{failureText(t, readFailure)}</Text>
186
+ ) : entitlements.length === 0 ? (
187
+ <Text style={muted}>{t.t("payments/subscription.nothing_held")}</Text>
188
+ ) : (
189
+ <View style={{ gap: theme.spacing.sm }}>
190
+ {entitlements.map((entitlement) => (
191
+ <Text key={entitlement.key} style={[theme.type.body, { color: theme.colors.text }]}>
192
+ <Text style={{ fontWeight: "700" }}>{entitlement.key}</Text>{" "}
193
+ <Text style={{ color: theme.colors.muted }}>{holding(t, entitlement)}</Text>
194
+ </Text>
195
+ ))}
196
+ </View>
197
+ )}
198
+
199
+ {failure && <Text style={muted}>{failureText(t, failure)}</Text>}
200
+
201
+ {sells && (
202
+ <View style={{ gap: theme.spacing.sm }}>
203
+ {/* Every hosted rail mints a portal, and the Worker picks whichever one this account bought on. The list
204
+ is the package's, so a rail added to Pithy reaches this screen without an edit. */}
205
+ {PAYMENTS_HOSTED_RAILS.some((rail) => rails[rail]) && (
206
+ <Action
207
+ theme={theme}
208
+ primary
209
+ label={t.t("payments/subscription.manage")}
210
+ disabled={managing}
211
+ onPress={() => void manage()}
212
+ />
213
+ )}
214
+ {/* Named one at a time: each store has its own sentence, and its own page. */}
215
+ {rails.apple && (
216
+ <Action theme={theme} label={t.t("payments/subscription.apple")} onPress={() => manageStore("apple")} />
217
+ )}
218
+ {rails.google && (
219
+ <Action theme={theme} label={t.t("payments/subscription.google")} onPress={() => manageStore("google")} />
220
+ )}
221
+ <Link to={paywallPath} style={{ color: theme.colors.muted }}>
222
+ {t.t("payments/subscription.more")}
223
+ </Link>
224
+ </View>
225
+ )}
226
+ </View>
227
+ );
228
+ }
229
+
230
+ export default function Subscription(): ReactNode {
231
+ // The paywall names itself; this reads the path it declared rather than keeping a copy.
232
+ const paywallPath = useScreenPath("paywall");
233
+ return (
234
+ <SubscriptionScreen
235
+ rails={paymentsConfig.rails}
236
+ choice={platformRails[devicePlatform()]}
237
+ paywallPath={paywallPath}
238
+ />
239
+ );
240
+ }
@@ -0,0 +1,349 @@
1
+ import type { AuthSession, AuthUser } from "@pithy-sh/auth/src/client/api";
2
+ import {
3
+ AUTH_UNREACHABLE,
4
+ AUTH_UNREADABLE,
5
+ callAuth,
6
+ signOut as endSession,
7
+ getSession as readSession,
8
+ } from "@pithy-sh/auth/src/client/api";
9
+ import { createHandoffVerifier, exchangeHandoff, handoffCallbackURL } from "@pithy-sh/auth/src/client/handoff";
10
+ import { accountRefusal } from "@pithy-sh/auth/src/client/lifecycle";
11
+ import { useRead } from "@pithy-sh/ui-core/src/read";
12
+ import { matchPath, type ScreenRole, screenPath } from "@pithy-sh/ui-core/src/routes";
13
+ import { CryptoDigestAlgorithm, digest, getRandomBytes } from "expo-crypto";
14
+ import { useCallback } from "react";
15
+ import { Linking } from "react-native";
16
+ import { authConfig, origin, universalLinkOrigin } from "./pithy-config";
17
+ import { appLink, onDevice, openExternal, parseQuery, queryString } from "./platform";
18
+ import { routeTable, useGo } from "./router";
19
+ import { secureStore, transport } from "./transport";
20
+
21
+ /**
22
+ * Session state, read from the Worker.
23
+ *
24
+ * The transport is `./transport`'s: a bearer credential in the secure store on a phone, the page's httpOnly cookie
25
+ * on the web. The requests themselves live in `@pithy-sh/auth/src/client/api` and `.../bearer`, which upgrade with
26
+ * a minor release; this file decides what the app does with their answers.
27
+ */
28
+
29
+ /** The signed-in user, as the session route returns them. */
30
+ export type SessionUser = AuthUser;
31
+
32
+ /** A live session, or `null` when nobody is signed in. */
33
+ export type Session = AuthSession;
34
+
35
+ /**
36
+ * The current session, or `null`. On a phone the stored credential is restored first, and reading the session is
37
+ * what tells the bearer session when its credential expires, so it rotates in time.
38
+ *
39
+ * Never throws. A Worker that could not be reached reads as signed out here.
40
+ */
41
+ export async function getSession(): Promise<Session> {
42
+ if (!authConfig.enabled) return null;
43
+ await transport.session();
44
+ const result = await readSession(transport.auth);
45
+ return result.ok ? result.value : null;
46
+ }
47
+
48
+ /**
49
+ * Hold the session a sign-in just answered with. On a phone that is the raw `token` in the answer's body, kept in
50
+ * the secure store before this resolves; on the web the Worker already set the cookie. `false` when a phone got an
51
+ * answer with no token in it, which is a sign-in that did not take.
52
+ */
53
+ export async function adoptSignIn(answer: unknown): Promise<boolean> {
54
+ if (!transport.bearer) return true;
55
+ const token = typeof answer === "object" && answer !== null ? (answer as { token?: unknown }).token : undefined;
56
+ if (typeof token !== "string" || token === "") return false;
57
+ await transport.adopt(token);
58
+ await readSession(transport.auth);
59
+ return true;
60
+ }
61
+
62
+ /** How a sign-in on a phone ended: the session is held, or the code the Worker refused it with. */
63
+ export type SignInOutcome = { ok: true } | { ok: false; code: string };
64
+
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === "object" && value !== null && !Array.isArray(value);
67
+ }
68
+
69
+ /**
70
+ * Who the provider says signed in, beside the token. Apple puts no name in its identity token and hands the name
71
+ * and address to the app on the first sign-in only, so the Worker learns them from this or never.
72
+ */
73
+ export interface IdTokenUser {
74
+ readonly name?: { readonly firstName?: string; readonly lastName?: string };
75
+ readonly email?: string;
76
+ }
77
+
78
+ /**
79
+ * Sign in with an id token a provider's own SDK handed the app: Apple's identity token, or Google's id token
80
+ * for the web client id. The Worker checks the token's audience and answers with the raw session token, which
81
+ * is adopted before this resolves. A phone only: the web signs in through the redirect.
82
+ */
83
+ export async function signInWithIdToken(
84
+ provider: "apple" | "google",
85
+ idToken: { token: string; nonce?: string; user?: IdTokenUser },
86
+ ): Promise<SignInOutcome> {
87
+ const result = await callAuth(
88
+ "/sign-in/social",
89
+ { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, idToken }) },
90
+ transport.auth,
91
+ isRecord,
92
+ );
93
+ if (!result.ok) return { ok: false, code: result.failure.code };
94
+ return (await adoptSignIn(result.value)) ? { ok: true } : { ok: false, code: AUTH_UNREADABLE.code };
95
+ }
96
+
97
+ /**
98
+ * Where the verifier of a redirect sign-in waits while the system browser has it. The secure store rather than
99
+ * memory, because a magic link is opened from the mail app, and the app may be gone by the time it returns.
100
+ */
101
+ export const HANDOFF_VERIFIER_KEY = "pithy.auth.handoff";
102
+
103
+ /** The code {@link completeHandoff} answers when no sign-in from this device is waiting for a code. */
104
+ export const NO_HANDOFF = "client/no_handoff";
105
+
106
+ /**
107
+ * The link a sign-in in the system browser returns to `path` on: the universal link when `universalLinkOrigin` is
108
+ * set, which only this app can claim, and the app's own scheme otherwise. The Worker's `trustedOrigins` names it.
109
+ */
110
+ export function returnLink(path: string): string {
111
+ return universalLinkOrigin === null ? appLink(path) : `${universalLinkOrigin}${path}`;
112
+ }
113
+
114
+ /**
115
+ * Start a redirect sign-in on a phone: make a verifier, keep it, and answer the `callbackURL` that returns to
116
+ * `path` in this app carrying the verifier's challenge. The Worker sends the browser back there with a one-time
117
+ * code instead of a cookie the app could never read. One sign-in waits at a time; starting another replaces it.
118
+ */
119
+ export async function beginHandoff(path: string): Promise<string> {
120
+ const { verifier, challenge } = await createHandoffVerifier({
121
+ randomBytes: getRandomBytes,
122
+ // A copy over a fresh `ArrayBuffer`: `BufferSource` takes no view over a `SharedArrayBuffer`.
123
+ sha256: (data) => digest(CryptoDigestAlgorithm.SHA256, new Uint8Array(data)),
124
+ });
125
+ await secureStore.setItem(HANDOFF_VERIFIER_KEY, verifier);
126
+ return handoffCallbackURL(returnLink(path), challenge);
127
+ }
128
+
129
+ /** Forget the waiting verifier: the sign-in it belonged to was abandoned. */
130
+ export async function abandonHandoff(): Promise<void> {
131
+ try {
132
+ await secureStore.deleteItem(HANDOFF_VERIFIER_KEY);
133
+ } catch {
134
+ // The next sign-in replaces it, and the Worker spends the code it stood for within a minute anyway.
135
+ }
136
+ }
137
+
138
+ async function trade(code: string): Promise<SignInOutcome> {
139
+ let verifier: string | null;
140
+ try {
141
+ verifier = await secureStore.getItem(HANDOFF_VERIFIER_KEY);
142
+ } catch {
143
+ verifier = null;
144
+ }
145
+ if (verifier === null || verifier === "") return { ok: false, code: NO_HANDOFF };
146
+ const traded = await exchangeHandoff({ code, verifier }, transport.auth);
147
+ // A refused code may be a stray link's, so the verifier waits on for the code its own sign-in returns with.
148
+ if (!traded.ok) return { ok: false, code: traded.failure.code };
149
+ await abandonHandoff();
150
+ return (await adoptSignIn(traded.value)) ? { ok: true } : { ok: false, code: AUTH_UNREADABLE.code };
151
+ }
152
+
153
+ const trades = new Map<string, Promise<SignInOutcome>>();
154
+
155
+ /**
156
+ * Trade the one-time code a redirect sign-in returned with for the session, and adopt it. The sign-in screen
157
+ * reads the code off the browser's answer and the callback screen reads it off the deep link, and on Android
158
+ * both see the same return: they share one trade per code, because the Worker honors a code once. A trade that
159
+ * never reached the Worker left the code unspent, so it is forgotten and the next call tries again.
160
+ */
161
+ export function completeHandoff(code: string): Promise<SignInOutcome> {
162
+ const running = trades.get(code);
163
+ if (running) return running;
164
+ const started = trade(code).then(
165
+ (outcome) => {
166
+ if (!outcome.ok && outcome.code === AUTH_UNREACHABLE.code) trades.delete(code);
167
+ return outcome;
168
+ },
169
+ (error: unknown) => {
170
+ trades.delete(code);
171
+ throw error;
172
+ },
173
+ );
174
+ trades.set(code, started);
175
+ return started;
176
+ }
177
+
178
+ /**
179
+ * The screen for an account that may not act, from the code its refusal carried, or `null` for any other code.
180
+ * The callback and the sign-in screen both route through it, by role.
181
+ */
182
+ export function refusedAccountScreen(code: string | null | undefined): ScreenRole | null {
183
+ const refused = accountRefusal(code);
184
+ if (refused === "suspended") return "account-suspended";
185
+ if (refused === "deleted") return "account-deleted";
186
+ return null;
187
+ }
188
+
189
+ /** End the session: the Worker's first, then the credential this device holds. */
190
+ export async function signOut(): Promise<void> {
191
+ await endSession(transport.auth);
192
+ await transport.forget();
193
+ }
194
+
195
+ /** {@link signOut}, then the sign-in screen. */
196
+ export function useSignOut(): () => Promise<void> {
197
+ const go = useGo();
198
+ return useCallback(async () => {
199
+ await signOut();
200
+ go(screenPath(await routeTable(), "sign-in"));
201
+ }, [go]);
202
+ }
203
+
204
+ /** The session as component state, plus a `refresh` for after a sign-in completes. */
205
+ export function useSession(): { session: Session; loading: boolean; refresh: () => void } {
206
+ const read = useRead("session", getSession);
207
+ return { session: read.value ?? null, loading: read.loading, refresh: read.refresh };
208
+ }
209
+
210
+ /** The query parameter a sign-in carries the request it should resume in. */
211
+ export const RESUME_PARAM = "resume";
212
+
213
+ /**
214
+ * `path` when it is a path on the Worker's own origin, or `null`. A rooted path with no second slash or backslash
215
+ * after the first, and no control character: a URL parser drops tab, CR and LF before it reads, so `"/\t/evil.test"`
216
+ * is a protocol-relative URL to it.
217
+ */
218
+ function ownPath(path: string): string | null {
219
+ if (!path.startsWith("/")) return null;
220
+ if (path[1] === "/" || path[1] === "\\") return null;
221
+ for (const char of path) {
222
+ const code = char.charCodeAt(0);
223
+ if (code < 0x20 || code === 0x7f || char === "\\") return null;
224
+ }
225
+ return path;
226
+ }
227
+
228
+ /** Where this screen's sign-in should resume, read off its query, or `null`. */
229
+ export function resumeTarget(search: string): string | null {
230
+ const path = parseQuery(search)[RESUME_PARAM];
231
+ return path === undefined ? null : ownPath(path);
232
+ }
233
+
234
+ /** `?resume=…` to carry a sign-in's resume target onward, or the empty string. */
235
+ export function resumeQuery(search: string): string {
236
+ const target = resumeTarget(search);
237
+ return target === null ? "" : queryString({ [RESUME_PARAM]: target });
238
+ }
239
+
240
+ /**
241
+ * Leave for a page in this tab, on the web. React Native Web's own `Linking.openURL` takes the target as a second
242
+ * argument that React Native's types leave out; `expo-linking` drops it, and a link opened without it is a new
243
+ * tab, which a popup blocker stops once an await has passed.
244
+ */
245
+ const pageLinking: { openURL(url: string, target?: string): Promise<unknown> } = Linking;
246
+
247
+ /** Leave the app for `url` in this tab, on the web: a provider's authorization page, which returns with a cookie. */
248
+ export async function leaveInPlace(url: string): Promise<void> {
249
+ await pageLinking.openURL(url, "_self");
250
+ }
251
+
252
+ /** Whether `target` is one of this app's own screens, such as an invitation, rather than a page the Worker serves. */
253
+ async function isScreen(target: string): Promise<boolean> {
254
+ const at = target.indexOf("?");
255
+ return matchPath((await routeTable()).patterns, at === -1 ? target : target.slice(0, at)) !== null;
256
+ }
257
+
258
+ /**
259
+ * After a sign-in: go where the query says to resume. `true` when the app is going there.
260
+ *
261
+ * One of this app's own screens, such as the invitation that sent somebody to sign in, is gone to in the app
262
+ * through `go`: pass the screen's `useGo()`. Anything else is the Worker's own page, such as an authorization
263
+ * request: on the web it loads in place, and on a phone it opens in the system browser.
264
+ */
265
+ export async function resumeAfterSignIn(search: string, go?: (to: string) => void): Promise<boolean> {
266
+ const target = resumeTarget(search);
267
+ if (target === null) return false;
268
+ if (go && (await isScreen(target))) {
269
+ go(target);
270
+ return true;
271
+ }
272
+ // The system browser holds no session of the app's: the Worker's page signs the person in there itself.
273
+ if (onDevice()) return openExternal(`${origin}${target}`);
274
+ await pageLinking.openURL(target, "_self");
275
+ return true;
276
+ }
277
+
278
+ /**
279
+ * Where a sign-in that took goes next, or `null` when the page is leaving for the Worker's own page in place.
280
+ *
281
+ * One of this app's own screens, such as the invitation that sent somebody to sign in, is that screen, on every
282
+ * platform. The Worker's own page, such as an authorization request, loads in place on the web and opens in the
283
+ * system browser on a phone, where the app goes home beneath it. With nothing to resume, home.
284
+ */
285
+ export async function afterSignIn(search: string): Promise<string | null> {
286
+ const target = resumeTarget(search);
287
+ if (target !== null && (await isScreen(target))) return target;
288
+ if ((await resumeAfterSignIn(search)) && !onDevice()) return null;
289
+ return "/";
290
+ }
291
+
292
+ /** The key the code screen's address waits under in this tab's session storage. */
293
+ const OTP_ADDRESS_KEY = "pithy.auth.otp-address";
294
+
295
+ /** The address, held in memory too: a phone has no session storage, and a private window may refuse it. */
296
+ let otpAddress: string | null = null;
297
+
298
+ /** The slice of the Web Storage API the held address uses. */
299
+ interface TabStorage {
300
+ getItem(key: string): string | null;
301
+ setItem(key: string, value: string): void;
302
+ removeItem(key: string): void;
303
+ }
304
+
305
+ /** This tab's session storage, or `null` where there is none or it refuses to be read. */
306
+ function tabStorage(): TabStorage | null {
307
+ try {
308
+ return (globalThis as { sessionStorage?: TabStorage }).sessionStorage ?? null;
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Hand the code screen the address a code was sent to, before going there.
316
+ *
317
+ * **Never in the URL.** A path or a query string is written to every request log between the browser and the
318
+ * Worker the moment a reload, a bookmark or a shared link sends it, so the address waits in memory and in this
319
+ * tab's session storage instead. A reload of the code screen keeps it; a new tab, a bookmark or a link somebody
320
+ * else opens does not, and the screen goes back to sign-in.
321
+ */
322
+ export function holdOtpAddress(email: string): void {
323
+ otpAddress = email;
324
+ try {
325
+ tabStorage()?.setItem(OTP_ADDRESS_KEY, email);
326
+ } catch {
327
+ // Storage refused: memory still holds it for this page.
328
+ }
329
+ }
330
+
331
+ /** The address the code screen was handed, or `null`. */
332
+ export function heldOtpAddress(): string | null {
333
+ if (otpAddress !== null) return otpAddress;
334
+ try {
335
+ return tabStorage()?.getItem(OTP_ADDRESS_KEY) ?? null;
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ /** Forget the held address, once the code has done its work. */
342
+ export function releaseOtpAddress(): void {
343
+ otpAddress = null;
344
+ try {
345
+ tabStorage()?.removeItem(OTP_ADDRESS_KEY);
346
+ } catch {
347
+ // Nothing to forget.
348
+ }
349
+ }
@@ -0,0 +1,100 @@
1
+ import { type TextStyle, useColorScheme } from "react-native";
2
+
3
+ /**
4
+ * The look every Pithy screen draws with: colors, spacing and type, light and dark. The palette is the one the
5
+ * web screens use, so an app on both reads as one product.
6
+ *
7
+ * Edit the values here and every screen follows. A screen never writes a color or a size of its own; it asks
8
+ * {@link useTheme} for the palette the device is in and reads a token, so a dark device gets the dark palette
9
+ * without a screen branching on it.
10
+ */
11
+ export interface Theme {
12
+ /** Light or dark: which palette this is. */
13
+ readonly scheme: "light" | "dark";
14
+ /** Colors, by the job they do. */
15
+ readonly colors: {
16
+ /** Behind everything. */
17
+ readonly background: string;
18
+ /** A raised surface: a card, an input. */
19
+ readonly surface: string;
20
+ /** Body text. */
21
+ readonly text: string;
22
+ /** Secondary text: a hint, a caption. */
23
+ readonly muted: string;
24
+ /** Borders and dividers. */
25
+ readonly border: string;
26
+ /** The one action a screen wants taken. */
27
+ readonly accent: string;
28
+ /** Text drawn on {@link Theme.colors.accent}. */
29
+ readonly onAccent: string;
30
+ /** A failure the reader has to act on. */
31
+ readonly danger: string;
32
+ };
33
+ /** Spacing, in density-independent pixels. */
34
+ readonly spacing: {
35
+ readonly xs: number;
36
+ readonly sm: number;
37
+ readonly md: number;
38
+ readonly lg: number;
39
+ readonly xl: number;
40
+ };
41
+ /** Corner radius for surfaces and buttons. */
42
+ readonly radius: number;
43
+ /** Text styles, by role. Colors are left to the screen, so one style serves both palettes. */
44
+ readonly type: {
45
+ readonly title: TextStyle;
46
+ readonly body: TextStyle;
47
+ readonly caption: TextStyle;
48
+ readonly button: TextStyle;
49
+ };
50
+ }
51
+
52
+ const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 } as const;
53
+
54
+ const type = {
55
+ title: { fontSize: 28, lineHeight: 34, fontWeight: "700" },
56
+ body: { fontSize: 16, lineHeight: 24, fontWeight: "400" },
57
+ caption: { fontSize: 13, lineHeight: 18, fontWeight: "400" },
58
+ button: { fontSize: 16, lineHeight: 20, fontWeight: "600" },
59
+ } as const satisfies Theme["type"];
60
+
61
+ /** The light palette. */
62
+ export const light: Theme = {
63
+ scheme: "light",
64
+ colors: {
65
+ background: "#fafaf6",
66
+ surface: "#ffffff",
67
+ text: "#111111",
68
+ muted: "#5f5d57",
69
+ border: "#e5e3db",
70
+ accent: "#d4a017",
71
+ onAccent: "#111111",
72
+ danger: "#b3261e",
73
+ },
74
+ spacing,
75
+ radius: 10,
76
+ type,
77
+ };
78
+
79
+ /** The dark palette. */
80
+ export const dark: Theme = {
81
+ scheme: "dark",
82
+ colors: {
83
+ background: "#111111",
84
+ surface: "#1a1a19",
85
+ text: "#fafaf6",
86
+ muted: "#9a988f",
87
+ border: "#2e2e2b",
88
+ accent: "#d4a017",
89
+ onAccent: "#111111",
90
+ danger: "#f2b8b5",
91
+ },
92
+ spacing,
93
+ radius: 10,
94
+ type,
95
+ };
96
+
97
+ /** The palette for the scheme the device is in now. Re-renders when the device switches. */
98
+ export function useTheme(): Theme {
99
+ return useColorScheme() === "dark" ? dark : light;
100
+ }