@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,260 @@
1
+ import { type ConsentView, decideConsent, readConsent } from "@pithy-sh/auth/src/client/mcp";
2
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
3
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
4
+ import { useRead } from "@pithy-sh/ui-core/src/read";
5
+ import { screenPath } from "@pithy-sh/ui-core/src/routes";
6
+ import * as Linking from "expo-linking";
7
+ import { type ReactNode, useState } from "react";
8
+ import { Linking as PageLinking, Pressable, Text, View } from "react-native";
9
+ import { authConfig } from "../../pithy-config";
10
+ import { onDevice, parseQuery, queryString } from "../../platform";
11
+ import { Redirect, routeTable, useSearch } from "../../router";
12
+ import { getSession, RESUME_PARAM } from "../../session";
13
+ import { type Theme, useTheme } from "../../theme";
14
+ import { transport } from "../../transport";
15
+
16
+ export const path = "/agent-consent";
17
+
18
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
19
+ //
20
+ // **An application is asking to act as you, and this is the one place a mistake here is irreversible.** Read this
21
+ // before restyling it.
22
+ //
23
+ // **The application's name is whatever it said it was.** Anybody can register a client and call it "POWERPLAY
24
+ // Official Sync"; nothing has checked it. So the name is shown as reported, in ordinary weight, and the emphasis goes
25
+ // to the one thing that cannot be spoofed: where the access goes — the redirect address, which the authorization
26
+ // server enforces. Making the name the headline is how this screen becomes a phishing page you shipped.
27
+ //
28
+ // **One decision, and nothing to tick.** Approving lets the application act as you in this app. The list is the
29
+ // consequence — what it will then be able to do, per capability, in each capability's own words — and it only ever
30
+ // lists what your role allows. Your role stays the ceiling: approving everything still never grants more than it.
31
+ //
32
+ // **Not signed in?** The authorization server sends people here to sign in too. The screen sends you to sign in
33
+ // with the request in the query, and the sign-in resumes it: in this page on the web, and in the system browser on
34
+ // a phone, because the authorization request belongs to the Worker's web pages rather than to the app.
35
+
36
+ /** Where the MCP capability mounts. Change this if `mcp({ path })` does. */
37
+ const MCP_PATH = "/mcp";
38
+
39
+ /** This screen's English, baked in — the only catalog that survives being copied into your repository. */
40
+ const EN = {
41
+ "auth/consent.title": "An application wants to act as you.",
42
+ "auth/consent.reported": "It calls itself {name}. That name is its own, and nobody has checked it.",
43
+ "auth/consent.unnamed": "It gave no name.",
44
+ "auth/consent.redirect": "Access will be sent to",
45
+ "auth/consent.redirect_note": "Approve only if you started this from that application.",
46
+ "auth/consent.can_do": "If you approve, it can:",
47
+ "auth/consent.count.one": "{reachable} of {count} operation in {capability}.",
48
+ "auth/consent.count.other": "{reachable} of {count} operations in {capability}.",
49
+ "auth/consent.withheld.one": "{count} more needs a role you do not hold.",
50
+ "auth/consent.withheld.other": "{count} more need a role you do not hold.",
51
+ "auth/consent.nothing": "Your role allows it nothing here yet. It gains what your role gains.",
52
+ "auth/consent.refused_scope": "{scope}: {reason}",
53
+ "auth/consent.not_granted.role": "Requested, and not granted, because your role does not include it.",
54
+ "auth/consent.not_granted.unknown": "Requested, and not granted, because this application has no such permission.",
55
+ "auth/consent.bound":
56
+ "Approving lets this application act as you. It can never do more than your role allows, and it loses anything your role loses.",
57
+ "auth/consent.approve": "Let it act as me",
58
+ "auth/consent.deny": "Don't allow",
59
+ "auth/consent.expired": "This request has expired. Start again from the application.",
60
+ "auth/consent.effect.writes": "changes data",
61
+ "auth/consent.effect.sends": "sends a message",
62
+ "auth/consent.effect.charges": "charges money",
63
+ "auth/consent.effect.deletes": "deletes data",
64
+ } satisfies MessageCatalog;
65
+
66
+ /** The parameters the authorization server signed the request with, which a fresh authorize request signs anew. */
67
+ const SIGNATURE_PARAMS = ["sig", "exp", "ba_iat", "ba_param", "ba_pl"];
68
+
69
+ /** The authorization request again, unsigned, so a sign-in can resume it and the server sign it afresh. */
70
+ function authorizeAgain(query: string): string {
71
+ const params = parseQuery(query);
72
+ for (const key of SIGNATURE_PARAMS) delete params[key];
73
+ return `${authConfig.basePath}/oauth2/authorize${queryString(params)}`;
74
+ }
75
+
76
+ /** The MCP routes, over this app's transport. */
77
+ function mcpOptions() {
78
+ return { ...transport.auth, basePath: MCP_PATH };
79
+ }
80
+
81
+ /** What arriving here comes to: the request to decide, a request that cannot be read, or a sign-in first. */
82
+ type Arrival = { kind: "view"; view: ConsentView } | { kind: "failed" } | { kind: "sign-in"; to: string };
83
+
84
+ async function arrive(query: string): Promise<Arrival> {
85
+ if ((await getSession()) === null) {
86
+ const resume = queryString({ [RESUME_PARAM]: authorizeAgain(query) });
87
+ return { kind: "sign-in", to: `${screenPath(await routeTable(), "sign-in")}${resume}` };
88
+ }
89
+ const result = await readConsent(query, mcpOptions());
90
+ return result.ok ? { kind: "view", view: result.value } : { kind: "failed" };
91
+ }
92
+
93
+ /**
94
+ * Leave for a page in this tab, on the web. React Native Web's own `Linking.openURL` takes the target as a second
95
+ * argument that React Native's types leave out; `expo-linking` passes only the address, which React Native Web
96
+ * opens in a new tab. Called on the object, because the method reads its own state.
97
+ */
98
+ const pageLinking: { openURL(url: string, target?: string): Promise<unknown> } = PageLinking;
99
+
100
+ /**
101
+ * Leave for the application's own address, with a code or a refusal. It is not this app: on the web the page loads
102
+ * it in place, and on a phone the system opens whatever answers it.
103
+ */
104
+ async function leaveFor(redirect: string): Promise<void> {
105
+ if (onDevice()) await Linking.openURL(redirect);
106
+ else await pageLinking.openURL(redirect, "_self");
107
+ }
108
+
109
+ function Screen(props: { theme: Theme; children?: ReactNode }): ReactNode {
110
+ const { theme } = props;
111
+ return (
112
+ <View
113
+ role="main"
114
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
115
+ >
116
+ {props.children}
117
+ </View>
118
+ );
119
+ }
120
+
121
+ function Button(props: {
122
+ theme: Theme;
123
+ label: string;
124
+ primary?: boolean;
125
+ disabled: boolean;
126
+ onPress: () => void;
127
+ }): ReactNode {
128
+ const { theme } = props;
129
+ return (
130
+ <Pressable
131
+ role="button"
132
+ disabled={props.disabled}
133
+ aria-disabled={props.disabled}
134
+ onPress={props.onPress}
135
+ style={{
136
+ borderWidth: 1,
137
+ borderColor: props.primary ? theme.colors.accent : theme.colors.border,
138
+ backgroundColor: props.primary ? theme.colors.accent : "transparent",
139
+ borderRadius: theme.radius,
140
+ paddingVertical: theme.spacing.sm,
141
+ paddingHorizontal: theme.spacing.md,
142
+ alignItems: "center",
143
+ opacity: props.disabled ? 0.5 : 1,
144
+ }}
145
+ >
146
+ <Text style={[theme.type.button, { color: props.primary ? theme.colors.onAccent : theme.colors.text }]}>
147
+ {props.label}
148
+ </Text>
149
+ </Pressable>
150
+ );
151
+ }
152
+
153
+ export default function Consent(): ReactNode {
154
+ const t = useTranslator(EN);
155
+ const theme = useTheme();
156
+ const query = useSearch().replace(/^\?/, "");
157
+ const arrival = useRead(`consent:${query}`, () => arrive(query)).value;
158
+ const [refused, setRefused] = useState(false);
159
+ const [busy, setBusy] = useState(false);
160
+
161
+ async function decide(accept: boolean): Promise<void> {
162
+ setBusy(true);
163
+ const result = await decideConsent(query, accept, mcpOptions());
164
+ if (!result.ok) {
165
+ setBusy(false);
166
+ setRefused(true);
167
+ return;
168
+ }
169
+ await leaveFor(result.value.redirect);
170
+ }
171
+
172
+ const body = [theme.type.body, { color: theme.colors.text }];
173
+ const muted = [theme.type.body, { color: theme.colors.muted }];
174
+
175
+ if (arrival?.kind === "sign-in") return <Redirect to={arrival.to} />;
176
+ if (refused || arrival?.kind === "failed") {
177
+ return (
178
+ <Screen theme={theme}>
179
+ <Text style={body}>{t.t("auth/consent.expired")}</Text>
180
+ </Screen>
181
+ );
182
+ }
183
+ if (arrival === undefined) return <Screen theme={theme} />;
184
+ const view = arrival.view;
185
+
186
+ return (
187
+ <Screen theme={theme}>
188
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
189
+ {t.t("auth/consent.title")}
190
+ </Text>
191
+ <Text style={muted}>
192
+ {view.client.reportedName === null
193
+ ? t.t("auth/consent.unnamed")
194
+ : t.t("auth/consent.reported", { name: view.client.reportedName })}
195
+ </Text>
196
+ <Text style={body}>
197
+ {t.t("auth/consent.redirect")} <Text style={{ fontWeight: "700" }}>{view.client.redirectUri}</Text>
198
+ </Text>
199
+ <Text style={muted}>{t.t("auth/consent.redirect_note")}</Text>
200
+
201
+ {view.capabilities.length === 0 ? (
202
+ <Text style={body}>{t.t("auth/consent.nothing")}</Text>
203
+ ) : (
204
+ <>
205
+ <Text style={body}>{t.t("auth/consent.can_do")}</Text>
206
+ {view.capabilities.map((row) => (
207
+ <View key={row.capability} style={{ gap: theme.spacing.xs }}>
208
+ <Text role="heading" style={[theme.type.button, { color: theme.colors.text }]}>
209
+ {row.capability}
210
+ </Text>
211
+ <View role="list" style={{ gap: theme.spacing.xs }}>
212
+ {row.operations.map((operation) => (
213
+ <View role="listitem" key={operation.tool}>
214
+ <Text style={body}>
215
+ {operation.summary}
216
+ {operation.effect === null ? null : (
217
+ <Text style={{ color: theme.colors.muted }}>
218
+ {" — "}
219
+ {t.t(`auth/consent.effect.${operation.effect}`)}
220
+ </Text>
221
+ )}
222
+ </Text>
223
+ </View>
224
+ ))}
225
+ </View>
226
+ <Text style={muted}>
227
+ {t.plural("auth/consent.count", row.total, {
228
+ reachable: String(row.reachable),
229
+ capability: row.capability,
230
+ })}
231
+ {row.total > row.reachable ? ` ${t.plural("auth/consent.withheld", row.total - row.reachable)}` : null}
232
+ </Text>
233
+ </View>
234
+ ))}
235
+ </>
236
+ )}
237
+
238
+ {view.requested
239
+ .filter((scope) => !scope.granted)
240
+ .map((scope) => (
241
+ <Text key={scope.scope} style={muted}>
242
+ {t.t("auth/consent.refused_scope", {
243
+ scope: scope.scope,
244
+ reason: t.t(`auth/consent.not_granted.${scope.reason ?? "unknown"}`),
245
+ })}
246
+ </Text>
247
+ ))}
248
+
249
+ <Text style={muted}>{t.t("auth/consent.bound")}</Text>
250
+ <Button
251
+ theme={theme}
252
+ primary
253
+ label={t.t("auth/consent.approve")}
254
+ disabled={busy}
255
+ onPress={() => void decide(true)}
256
+ />
257
+ <Button theme={theme} label={t.t("auth/consent.deny")} disabled={busy} onPress={() => void decide(false)} />
258
+ </Screen>
259
+ );
260
+ }
@@ -0,0 +1,74 @@
1
+ import { HANDOFF_CODE_PARAM } from "@pithy-sh/auth/src/client/handoff";
2
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
3
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
4
+ import { useRead } from "@pithy-sh/ui-core/src/read";
5
+ import { screenPath } from "@pithy-sh/ui-core/src/routes";
6
+ import type { ReactNode } from "react";
7
+ import { Text, View } from "react-native";
8
+ import { onDevice, queryString, searchParam } from "../../platform";
9
+ import { Redirect, routeTable, useSearch } from "../../router";
10
+ import { afterSignIn, completeHandoff, getSession, refusedAccountScreen } from "../../session";
11
+ import { useTheme } from "../../theme";
12
+
13
+ export const path = "/callback";
14
+
15
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
16
+ //
17
+ // `sign-in.tsx` builds every link that returns here from the `path` above, so renaming it moves both ends at
18
+ // once. The round trip is the one flow nobody already signed in can test, so it must not be two strings that only
19
+ // happen to agree.
20
+
21
+ /** This screen's English, baked in: the only catalog that survives being copied into your repository. */
22
+ const EN = {
23
+ "auth/callback.title": "Signing you in.",
24
+ "auth/callback.body": "One moment.",
25
+ } satisfies MessageCatalog;
26
+
27
+ /**
28
+ * Where this landing goes next, or `null` when the app is leaving for the Worker's own page.
29
+ *
30
+ * On the web the Worker set the session cookie before anybody lands here, so this asks who you are and moves on.
31
+ * On a phone the deep link carries a one-time code instead, which is traded for the session with the verifier
32
+ * the sign-in left in the secure store; a code this device never asked for trades for nothing. Either way a
33
+ * refusal for the account's standing arrives as `?error=` and goes on to the screen for that state, a provider's
34
+ * refusal goes on to the sign-in screen that says who refused, and a sign-in a screen or an application's
35
+ * authorization sent somebody to resumes it.
36
+ */
37
+ async function landing(search: string): Promise<{ to: string } | null> {
38
+ const table = await routeTable();
39
+ const error = searchParam(search, "error");
40
+ const refused = refusedAccountScreen(error);
41
+ if (refused) return { to: screenPath(table, refused) };
42
+ const provider = searchParam(search, "provider");
43
+ if (error !== null && provider !== null) {
44
+ return { to: `${screenPath(table, "sign-in")}${queryString({ provider, error })}` };
45
+ }
46
+ const code = onDevice() ? searchParam(search, HANDOFF_CODE_PARAM) : null;
47
+ if (code !== null) {
48
+ const traded = await completeHandoff(code);
49
+ const refusedTrade = traded.ok ? null : refusedAccountScreen(traded.code);
50
+ if (refusedTrade) return { to: screenPath(table, refusedTrade) };
51
+ }
52
+ if ((await getSession()) === null) return { to: screenPath(table, "sign-in") };
53
+ const next = await afterSignIn(search);
54
+ return next === null ? null : { to: next };
55
+ }
56
+
57
+ export default function Callback(): ReactNode {
58
+ const t = useTranslator(EN);
59
+ const theme = useTheme();
60
+ const search = useSearch();
61
+ const next = useRead("callback", () => landing(search), { once: true }).value;
62
+
63
+ return (
64
+ <View
65
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
66
+ >
67
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
68
+ {t.t("auth/callback.title")}
69
+ </Text>
70
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("auth/callback.body")}</Text>
71
+ {next ? <Redirect to={next.to} replace /> : null}
72
+ </View>
73
+ );
74
+ }
@@ -0,0 +1,230 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import { type ActableOrganization, chooseOrganization, listOrganizations } from "@pithy-sh/organization/src/client/api";
4
+ import { organizationRefusal } from "@pithy-sh/organization/src/client/lifecycle";
5
+ import { useRead } from "@pithy-sh/ui-core/src/read";
6
+ import { screenPath } from "@pithy-sh/ui-core/src/routes";
7
+ import { type ReactNode, useState } from "react";
8
+ import { Image, Pressable, Text, View } from "react-native";
9
+ import { onDevice } from "../../platform";
10
+ import { Redirect, routeTable } from "../../router";
11
+ import { type Theme, useTheme } from "../../theme";
12
+ import { transport, type WorkerImage } from "../../transport";
13
+
14
+ export const path = "/choose-organization";
15
+
16
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
17
+ //
18
+ // **Three arrivals, one screen, and getting them wrong is the whole reason this ships rather than being
19
+ // left to every adopter.**
20
+ //
21
+ // Several memberships — draw the picker. This is the case people picture.
22
+ // Exactly one — go straight through without asking, *and still record the choice*, so what is in force
23
+ // is a row rather than an inference every later request has to make again.
24
+ // None — a different answer entirely. Somebody who belongs nowhere is not choosing; they are waiting
25
+ // for an invitation, and a picker with an empty list tells them nothing. And somebody whose every
26
+ // account is suspended does not belong nowhere: the list counts those, and they go on to the
27
+ // suspended screen, by role, rather than being told to ask for an invitation.
28
+ //
29
+ // The middle one is the one that gets written wrong. A screen that renders a one-item list and waits is
30
+ // asking somebody to confirm a decision that was never theirs; a screen that redirects without writing
31
+ // leaves every subsequent request resolving the same membership over again.
32
+ //
33
+ // **Leaving remounts every screen, not only this one.** What is in force decides what every request may
34
+ // reach, and a plain route change keeps whatever the previous account's data had already populated — a
35
+ // roster, a header, a cached list — while the session underneath now answers for somebody else. On the web
36
+ // that is a document load; in the app the router's `load` does the same, and nothing a screen held survives.
37
+ const EN = {
38
+ "organization/choose.title": "Choose an account",
39
+ "organization/choose.subtitle": "You belong to more than one.",
40
+ "organization/choose.role": "You are {role} here.",
41
+ "organization/choose.empty.title": "No accounts yet",
42
+ "organization/choose.empty.body": "You are not a member of any account. Ask somebody to invite you.",
43
+ "organization/choose.failed": "We couldn't load your accounts.",
44
+ "organization/choose.retry": "Try again",
45
+ } satisfies MessageCatalog;
46
+
47
+ /**
48
+ * Where to go once something is in force.
49
+ *
50
+ * The app's root, and deliberately not a `ScreenRole`. The roles exist for screens a *guard* sends
51
+ * somebody to — sign-in, paywall, subscription — and nothing guards its way here: the chooser is
52
+ * arrived at, not redirected to. An adopter whose home is elsewhere changes this line, which is the one
53
+ * edit a copied screen is for.
54
+ */
55
+ const DESTINATION = "/";
56
+
57
+ /** One account the picker draws, with its mark ready for `<Image>`, or `null` for its initial. */
58
+ interface Choice {
59
+ readonly organization: ActableOrganization;
60
+ readonly mark: WorkerImage | null;
61
+ }
62
+
63
+ /** What an arrival, or a choice, comes to: the picker, a failure, or somewhere to go. */
64
+ type Answer = { kind: "list"; choices: Choice[] } | { kind: "failed" } | { kind: "leave"; to: string; load: boolean };
65
+
66
+ /**
67
+ * How to draw an organization's mark, or `null` for its initial.
68
+ *
69
+ * A raster is a path on the Worker, which `<Image>` fetches itself, so the transport hands it the origin and, on a
70
+ * phone, the credential. A vector arrives as its own `data:` value: the web draws it, and React Native's `<Image>`
71
+ * cannot, so a phone shows the initial.
72
+ */
73
+ export async function markSource(mark: string | null): Promise<WorkerImage | null> {
74
+ if (mark === null) return null;
75
+ if (mark.startsWith("data:")) return onDevice() && mark.startsWith("data:image/svg+xml") ? null : { uri: mark };
76
+ return transport.image(mark);
77
+ }
78
+
79
+ /**
80
+ * Record the choice, then leave, remounting every screen. See the note above for why.
81
+ *
82
+ * An account suspended since the list was drawn says so, on its own screen, by role. Nothing is in force then, so
83
+ * a plain navigation carries nothing over. Any other refusal — including the ordinary 404 a caller naming an
84
+ * account they do not belong to gets, which is the same answer as one that does not exist — is a failure.
85
+ */
86
+ async function choose(organizationId: string): Promise<Answer> {
87
+ const result = await chooseOrganization(organizationId, transport.organization);
88
+ if (result.ok) return { kind: "leave", to: DESTINATION, load: true };
89
+ if (organizationRefusal(result.failure.code) === "suspended") {
90
+ return { kind: "leave", to: screenPath(await routeTable(), "organization-suspended"), load: false };
91
+ }
92
+ return { kind: "failed" };
93
+ }
94
+
95
+ /** Read the memberships, and decide which of the three arrivals this is. */
96
+ async function arrive(): Promise<Answer> {
97
+ const result = await listOrganizations(transport.organization);
98
+ if (!result.ok) return { kind: "failed" };
99
+ const { organizations, acting, suspended } = result.value;
100
+ // Nothing to act in, and something suspended: that is the suspended screen's answer, not "no accounts yet".
101
+ if (organizations.length === 0 && (suspended ?? 0) > 0) {
102
+ return { kind: "leave", to: screenPath(await routeTable(), "organization-suspended"), load: false };
103
+ }
104
+ // Somebody with exactly one account is put into it rather than asked. The write still happens, so `acting` is a
105
+ // row from here on and `chosen` says it was not a decision.
106
+ const only = organizations.length === 1 && acting === null ? organizations[0] : undefined;
107
+ if (only) return choose(only.id);
108
+ const choices = await Promise.all(
109
+ organizations.map(async (organization) => ({ organization, mark: await markSource(organization.mark) })),
110
+ );
111
+ return { kind: "list", choices };
112
+ }
113
+
114
+ export default function ChooseOrganization(): ReactNode {
115
+ const t = useTranslator(EN);
116
+ const theme = useTheme();
117
+ // Once per arrival, StrictMode included: the one-membership path writes the choice.
118
+ const arrival = useRead("organizations", arrive, { once: true });
119
+ const [chosen, setChosen] = useState<Answer | null>(null);
120
+ const [busy, setBusy] = useState(false);
121
+
122
+ async function pick(organizationId: string): Promise<void> {
123
+ setBusy(true);
124
+ const answer = await choose(organizationId);
125
+ setBusy(false);
126
+ setChosen(answer);
127
+ }
128
+
129
+ function retry(): void {
130
+ setChosen(null);
131
+ arrival.refresh();
132
+ }
133
+
134
+ const screen = {
135
+ flex: 1,
136
+ padding: theme.spacing.lg,
137
+ gap: theme.spacing.md,
138
+ backgroundColor: theme.colors.background,
139
+ };
140
+ const answer = chosen ?? (arrival.loading ? null : (arrival.value ?? null));
141
+ if (answer?.kind === "leave") return <Redirect to={answer.to} load={answer.load} />;
142
+
143
+ if (answer?.kind === "failed" || (answer === null && arrival.failure !== null && !arrival.loading)) {
144
+ return (
145
+ <View style={screen}>
146
+ <Text style={[theme.type.body, { color: theme.colors.text }]}>{t.t("organization/choose.failed")}</Text>
147
+ <Pressable role="button" onPress={retry} style={buttonStyle(theme)}>
148
+ <Text style={[theme.type.button, { color: theme.colors.text }]}>{t.t("organization/choose.retry")}</Text>
149
+ </Pressable>
150
+ </View>
151
+ );
152
+ }
153
+
154
+ // The read has not answered. Nothing is drawn rather than a spinner: the one-membership path leaves
155
+ // without ever rendering, and a flash of a picker for an account nobody chose is worse than a pause.
156
+ if (answer === null) return <View style={screen} />;
157
+
158
+ if (answer.choices.length === 0) {
159
+ return (
160
+ <View style={screen}>
161
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
162
+ {t.t("organization/choose.empty.title")}
163
+ </Text>
164
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("organization/choose.empty.body")}</Text>
165
+ </View>
166
+ );
167
+ }
168
+
169
+ return (
170
+ <View style={screen}>
171
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
172
+ {t.t("organization/choose.title")}
173
+ </Text>
174
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("organization/choose.subtitle")}</Text>
175
+ <View role="list" style={{ gap: theme.spacing.sm }}>
176
+ {answer.choices.map(({ organization, mark }) => (
177
+ <View role="listitem" key={organization.id}>
178
+ <Pressable
179
+ role="button"
180
+ disabled={busy}
181
+ onPress={() => void pick(organization.id)}
182
+ style={[
183
+ buttonStyle(theme),
184
+ { flexDirection: "row", alignItems: "center", gap: theme.spacing.md, opacity: busy ? 0.6 : 1 },
185
+ ]}
186
+ >
187
+ <Mark name={organization.name} source={mark} theme={theme} />
188
+ <View style={{ flexShrink: 1, gap: theme.spacing.xs }}>
189
+ <Text style={[theme.type.button, { color: theme.colors.text }]}>{organization.name}</Text>
190
+ <Text style={[theme.type.caption, { color: theme.colors.muted }]}>
191
+ {t.t("organization/choose.role", { role: organization.role })}
192
+ </Text>
193
+ </View>
194
+ </Pressable>
195
+ </View>
196
+ ))}
197
+ </View>
198
+ </View>
199
+ );
200
+ }
201
+
202
+ const MARK_SIZE = 40;
203
+
204
+ /** The mark, or the name's initial when there is none to draw. The initial is an answer, not a placeholder. */
205
+ function Mark(props: { name: string; source: WorkerImage | null; theme: Theme }): ReactNode {
206
+ const { theme } = props;
207
+ const frame = { width: MARK_SIZE, height: MARK_SIZE, borderRadius: theme.radius };
208
+ if (props.source !== null) {
209
+ return <Image source={props.source} accessibilityIgnoresInvertColors alt="" style={frame} />;
210
+ }
211
+ return (
212
+ <View
213
+ aria-hidden
214
+ style={[frame, { alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.border }]}
215
+ >
216
+ <Text style={[theme.type.button, { color: theme.colors.text }]}>{props.name.slice(0, 1).toUpperCase()}</Text>
217
+ </View>
218
+ );
219
+ }
220
+
221
+ function buttonStyle(theme: Theme) {
222
+ return {
223
+ borderWidth: 1,
224
+ borderColor: theme.colors.border,
225
+ borderRadius: theme.radius,
226
+ backgroundColor: theme.colors.surface,
227
+ paddingVertical: theme.spacing.sm,
228
+ paddingHorizontal: theme.spacing.md,
229
+ } as const;
230
+ }