@ramonclaudio/create-vexpo 0.1.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 +50 -0
- package/dist/index.js +183 -0
- package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
- package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
- package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
- package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
- package/dist/templates/default/.eas/workflows/release.yml +44 -0
- package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
- package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
- package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
- package/dist/templates/default/.github/workflows/check.yml +28 -0
- package/dist/templates/default/.maestro/launch.yaml +18 -0
- package/dist/templates/default/AGENTS.md +79 -0
- package/dist/templates/default/DESIGN.md +331 -0
- package/dist/templates/default/LICENSE +21 -0
- package/dist/templates/default/README.md +153 -0
- package/dist/templates/default/SETUP.md +618 -0
- package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
- package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
- package/dist/templates/default/_easignore +22 -0
- package/dist/templates/default/_editorconfig +9 -0
- package/dist/templates/default/_env.example +34 -0
- package/dist/templates/default/_fingerprintignore +24 -0
- package/dist/templates/default/_gitattributes +7 -0
- package/dist/templates/default/_gitignore +69 -0
- package/dist/templates/default/_oxfmtrc.json +3 -0
- package/dist/templates/default/_oxlintrc.json +34 -0
- package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
- package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
- package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
- package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
- package/dist/templates/default/app/(app)/_layout.tsx +73 -0
- package/dist/templates/default/app/(app)/debug.tsx +389 -0
- package/dist/templates/default/app/(app)/help.tsx +254 -0
- package/dist/templates/default/app/(app)/linked.tsx +116 -0
- package/dist/templates/default/app/(app)/privacy.tsx +159 -0
- package/dist/templates/default/app/(app)/profile.tsx +915 -0
- package/dist/templates/default/app/(app)/sessions.tsx +191 -0
- package/dist/templates/default/app/(app)/welcome.tsx +140 -0
- package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
- package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
- package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
- package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
- package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
- package/dist/templates/default/app/+native-intent.tsx +14 -0
- package/dist/templates/default/app/+not-found.tsx +51 -0
- package/dist/templates/default/app/_layout.tsx +102 -0
- package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
- package/dist/templates/default/app-store/screenshots/README.md +13 -0
- package/dist/templates/default/app.config.ts +201 -0
- package/dist/templates/default/app.json +11 -0
- package/dist/templates/default/assets/brand-icon-dark.png +0 -0
- package/dist/templates/default/assets/brand-icon-light.png +0 -0
- package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
- package/dist/templates/default/assets/icon.png +0 -0
- package/dist/templates/default/assets/sounds/notification.wav +0 -0
- package/dist/templates/default/assets/splash-image-dark.png +0 -0
- package/dist/templates/default/assets/splash-image-light.png +0 -0
- package/dist/templates/default/bun.lock +1860 -0
- package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
- package/dist/templates/default/components/auth/password-field.tsx +121 -0
- package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
- package/dist/templates/default/components/ui/convex-error.tsx +32 -0
- package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
- package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
- package/dist/templates/default/components/ui/material.tsx +94 -0
- package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
- package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
- package/dist/templates/default/components/ui/skeleton.tsx +107 -0
- package/dist/templates/default/components/ui/status-text.tsx +49 -0
- package/dist/templates/default/components/ui/update-banner.tsx +82 -0
- package/dist/templates/default/constants/layout.ts +102 -0
- package/dist/templates/default/constants/theme.ts +401 -0
- package/dist/templates/default/constants/ui.ts +77 -0
- package/dist/templates/default/convex/_generated/api.d.ts +77 -0
- package/dist/templates/default/convex/_generated/api.js +23 -0
- package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
- package/dist/templates/default/convex/_generated/server.d.ts +143 -0
- package/dist/templates/default/convex/_generated/server.js +93 -0
- package/dist/templates/default/convex/admin.ts +102 -0
- package/dist/templates/default/convex/auth.config.ts +6 -0
- package/dist/templates/default/convex/auth.ts +335 -0
- package/dist/templates/default/convex/constants.ts +46 -0
- package/dist/templates/default/convex/convex.config.ts +11 -0
- package/dist/templates/default/convex/crons.ts +42 -0
- package/dist/templates/default/convex/email.ts +109 -0
- package/dist/templates/default/convex/env.ts +31 -0
- package/dist/templates/default/convex/errors.ts +33 -0
- package/dist/templates/default/convex/functions.ts +54 -0
- package/dist/templates/default/convex/http.ts +176 -0
- package/dist/templates/default/convex/log.ts +81 -0
- package/dist/templates/default/convex/pushTokens.ts +114 -0
- package/dist/templates/default/convex/rateLimit.ts +92 -0
- package/dist/templates/default/convex/schema.ts +28 -0
- package/dist/templates/default/convex/tsconfig.json +18 -0
- package/dist/templates/default/convex/users.ts +279 -0
- package/dist/templates/default/convex/validators.ts +74 -0
- package/dist/templates/default/convex/webhook.ts +193 -0
- package/dist/templates/default/convex.json +6 -0
- package/dist/templates/default/eas.json +56 -0
- package/dist/templates/default/fingerprint.config.js +9 -0
- package/dist/templates/default/hooks/use-debounce.ts +20 -0
- package/dist/templates/default/hooks/use-deep-link.ts +43 -0
- package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
- package/dist/templates/default/hooks/use-network.ts +11 -0
- package/dist/templates/default/hooks/use-notifications.ts +107 -0
- package/dist/templates/default/hooks/use-onboarding.ts +15 -0
- package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
- package/dist/templates/default/hooks/use-theme.ts +53 -0
- package/dist/templates/default/hooks/use-updates.ts +86 -0
- package/dist/templates/default/lib/a11y.ts +5 -0
- package/dist/templates/default/lib/app.ts +14 -0
- package/dist/templates/default/lib/assets.ts +17 -0
- package/dist/templates/default/lib/auth-client.ts +21 -0
- package/dist/templates/default/lib/convex-auth.tsx +79 -0
- package/dist/templates/default/lib/deep-link.ts +71 -0
- package/dist/templates/default/lib/dev-menu.ts +119 -0
- package/dist/templates/default/lib/device.ts +40 -0
- package/dist/templates/default/lib/dynamic-font.ts +49 -0
- package/dist/templates/default/lib/env.ts +10 -0
- package/dist/templates/default/lib/haptics.ts +24 -0
- package/dist/templates/default/lib/notifications.ts +276 -0
- package/dist/templates/default/lib/preferences.ts +45 -0
- package/dist/templates/default/lib/schemas.ts +137 -0
- package/dist/templates/default/lib/storage.ts +47 -0
- package/dist/templates/default/lib/updates.ts +107 -0
- package/dist/templates/default/metro.config.js +14 -0
- package/dist/templates/default/package.json +129 -0
- package/dist/templates/default/patches/PR-368.patch +91 -0
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- package/dist/templates/default/plugins/README.md +9 -0
- package/dist/templates/default/plugins/with-auto-signing.js +45 -0
- package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
- package/dist/templates/default/scripts/README.md +36 -0
- package/dist/templates/default/scripts/_run.mjs +77 -0
- package/dist/templates/default/scripts/clean.ts +543 -0
- package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
- package/dist/templates/default/store.config.json +58 -0
- package/dist/templates/default/tsconfig.json +13 -0
- package/dist/templates/default/vitest.config.ts +21 -0
- package/package.json +69 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Webhook handler factory for Convex HTTP routes.
|
|
2
|
+
//
|
|
3
|
+
// Every webhook source we accept (EAS Build/Submit, Resend delivery events,
|
|
4
|
+
// future Stripe / GitHub) follows the same shape: a signed POST with a
|
|
5
|
+
// shared secret, JSON body, signature in a header. Centralising the
|
|
6
|
+
// boilerplate here means each route gets:
|
|
7
|
+
//
|
|
8
|
+
// - Body-size cap (defend against runaway upload eating the function budget)
|
|
9
|
+
// - Constant-time signature verification (HMAC, configurable algorithm)
|
|
10
|
+
// - Optional replay protection (timestamp window check)
|
|
11
|
+
// - Per-request correlation ID + structured access log with timing
|
|
12
|
+
// - Uniform JSON error responses with a `requestId` for grepping
|
|
13
|
+
//
|
|
14
|
+
// The handler the caller supplies receives the parsed JSON body, raw text
|
|
15
|
+
// body (in case re-hashing is needed), and the request ID. It returns a
|
|
16
|
+
// `Response`; the factory wraps timing + logging around it.
|
|
17
|
+
|
|
18
|
+
import type { GenericActionCtx } from "convex/server";
|
|
19
|
+
|
|
20
|
+
import { log, newRequestId } from "./log.ts";
|
|
21
|
+
|
|
22
|
+
export type SignatureAlgorithm = "sha1" | "sha256";
|
|
23
|
+
|
|
24
|
+
export type WithWebhookOptions = {
|
|
25
|
+
/** Stable name in logs. Example: "eas-webhook", "resend-webhook". */
|
|
26
|
+
source: string;
|
|
27
|
+
/** Request header carrying the signature. Example: "expo-signature". */
|
|
28
|
+
signatureHeader: string;
|
|
29
|
+
/** Convex env var holding the shared secret. Example: "EAS_WEBHOOK_SECRET". */
|
|
30
|
+
secretEnv: string;
|
|
31
|
+
/** HMAC algorithm. EAS uses SHA-1, Stripe uses SHA-256, Resend uses Svix's HMAC-SHA256. */
|
|
32
|
+
algorithm: SignatureAlgorithm;
|
|
33
|
+
/**
|
|
34
|
+
* Prefix expected in the signature header before the hex digest.
|
|
35
|
+
* EAS sends `sha1=<hex>`, Stripe sends `t=<ts>,v1=<hex>`, etc. Default `""`.
|
|
36
|
+
*/
|
|
37
|
+
signaturePrefix?: string;
|
|
38
|
+
/** Cap the request body size in bytes. Defaults to 1 MiB. */
|
|
39
|
+
maxBodyBytes?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Optional max age of the request in seconds. If set, the handler reads
|
|
42
|
+
* the timestamp from the named header and rejects if too old. Defaults
|
|
43
|
+
* to "no replay window check."
|
|
44
|
+
*/
|
|
45
|
+
replay?: {
|
|
46
|
+
header: string;
|
|
47
|
+
/** Allowed clock skew in seconds. */
|
|
48
|
+
maxAgeSeconds: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type WebhookContext = {
|
|
53
|
+
requestId: string;
|
|
54
|
+
rawBody: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type WebhookHandler<T> = (
|
|
58
|
+
ctx: GenericActionCtx<Record<string, never>>,
|
|
59
|
+
payload: T,
|
|
60
|
+
webhookCtx: WebhookContext,
|
|
61
|
+
) => Promise<Response> | Response;
|
|
62
|
+
|
|
63
|
+
export function withWebhook<T = unknown>(
|
|
64
|
+
opts: WithWebhookOptions,
|
|
65
|
+
handler: WebhookHandler<T>,
|
|
66
|
+
): (ctx: GenericActionCtx<Record<string, never>>, req: Request) => Promise<Response> {
|
|
67
|
+
const maxBodyBytes = opts.maxBodyBytes ?? 1024 * 1024;
|
|
68
|
+
const prefix = opts.signaturePrefix ?? "";
|
|
69
|
+
|
|
70
|
+
return async (ctx, req) => {
|
|
71
|
+
const start = Date.now();
|
|
72
|
+
const requestId = newRequestId();
|
|
73
|
+
const baseFields = { event: "webhook", requestId, source: opts.source };
|
|
74
|
+
|
|
75
|
+
const secret = process.env[opts.secretEnv];
|
|
76
|
+
if (!secret) {
|
|
77
|
+
log.error({ ...baseFields, event: "webhook.misconfigured", secretEnv: opts.secretEnv });
|
|
78
|
+
return jsonError(503, "webhook secret not configured", requestId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const signatureHeaderValue = req.headers.get(opts.signatureHeader);
|
|
82
|
+
if (!signatureHeaderValue) {
|
|
83
|
+
log.warn({ ...baseFields, event: "webhook.missing_signature" });
|
|
84
|
+
return jsonError(401, `missing ${opts.signatureHeader}`, requestId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (opts.replay) {
|
|
88
|
+
const tsHeader = req.headers.get(opts.replay.header);
|
|
89
|
+
if (!tsHeader) {
|
|
90
|
+
log.warn({ ...baseFields, event: "webhook.missing_timestamp" });
|
|
91
|
+
return jsonError(401, `missing ${opts.replay.header}`, requestId);
|
|
92
|
+
}
|
|
93
|
+
const ts = Number(tsHeader);
|
|
94
|
+
if (!Number.isFinite(ts)) {
|
|
95
|
+
log.warn({ ...baseFields, event: "webhook.bad_timestamp", tsHeader });
|
|
96
|
+
return jsonError(401, "bad timestamp", requestId);
|
|
97
|
+
}
|
|
98
|
+
const ageSeconds = (Date.now() - ts) / 1000;
|
|
99
|
+
if (Math.abs(ageSeconds) > opts.replay.maxAgeSeconds) {
|
|
100
|
+
log.warn({ ...baseFields, event: "webhook.stale", ageSeconds });
|
|
101
|
+
return jsonError(401, "timestamp out of window", requestId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const contentLength = Number(req.headers.get("content-length") ?? "0");
|
|
106
|
+
if (contentLength > maxBodyBytes) {
|
|
107
|
+
log.warn({ ...baseFields, event: "webhook.too_large", contentLength });
|
|
108
|
+
return jsonError(413, "payload too large", requestId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const rawBody = await req.text();
|
|
112
|
+
if (rawBody.length > maxBodyBytes) {
|
|
113
|
+
log.warn({ ...baseFields, event: "webhook.too_large", bytes: rawBody.length });
|
|
114
|
+
return jsonError(413, "payload too large", requestId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const computed = prefix + (await hmacHex(opts.algorithm, secret, rawBody));
|
|
118
|
+
if (!timingSafeEqual(computed, signatureHeaderValue)) {
|
|
119
|
+
log.warn({ ...baseFields, event: "webhook.bad_signature" });
|
|
120
|
+
return jsonError(401, "signature mismatch", requestId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let payload: T;
|
|
124
|
+
try {
|
|
125
|
+
payload = JSON.parse(rawBody) as T;
|
|
126
|
+
} catch {
|
|
127
|
+
log.warn({ ...baseFields, event: "webhook.bad_json" });
|
|
128
|
+
return jsonError(400, "invalid json", requestId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const response = await handler(ctx, payload, { requestId, rawBody });
|
|
133
|
+
log.info({
|
|
134
|
+
...baseFields,
|
|
135
|
+
event: "webhook.ok",
|
|
136
|
+
status: response.status,
|
|
137
|
+
durationMs: Date.now() - start,
|
|
138
|
+
});
|
|
139
|
+
// Always attach X-Request-Id to the response so callers can correlate
|
|
140
|
+
// logs even when the handler doesn't set it explicitly. Don't clobber
|
|
141
|
+
// a value the handler already chose.
|
|
142
|
+
if (!response.headers.get("X-Request-Id")) {
|
|
143
|
+
const headers = new Headers(response.headers);
|
|
144
|
+
headers.set("X-Request-Id", requestId);
|
|
145
|
+
return new Response(response.body, {
|
|
146
|
+
status: response.status,
|
|
147
|
+
statusText: response.statusText,
|
|
148
|
+
headers,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return response;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
log.error({
|
|
154
|
+
...baseFields,
|
|
155
|
+
event: "webhook.handler_error",
|
|
156
|
+
durationMs: Date.now() - start,
|
|
157
|
+
err,
|
|
158
|
+
});
|
|
159
|
+
return jsonError(500, "handler error", requestId);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function jsonError(status: number, message: string, requestId: string): Response {
|
|
165
|
+
return new Response(JSON.stringify({ error: message, requestId }), {
|
|
166
|
+
status,
|
|
167
|
+
headers: { "Content-Type": "application/json", "X-Request-Id": requestId },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function hmacHex(
|
|
172
|
+
algorithm: SignatureAlgorithm,
|
|
173
|
+
secret: string,
|
|
174
|
+
body: string,
|
|
175
|
+
): Promise<string> {
|
|
176
|
+
const enc = new TextEncoder();
|
|
177
|
+
const key = await crypto.subtle.importKey(
|
|
178
|
+
"raw",
|
|
179
|
+
enc.encode(secret),
|
|
180
|
+
{ name: "HMAC", hash: algorithm === "sha1" ? "SHA-1" : "SHA-256" },
|
|
181
|
+
false,
|
|
182
|
+
["sign"],
|
|
183
|
+
);
|
|
184
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
|
|
185
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
189
|
+
if (a.length !== b.length) return false;
|
|
190
|
+
let diff = 0;
|
|
191
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
192
|
+
return diff === 0;
|
|
193
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cli": {
|
|
3
|
+
"version": ">= 16.0.1",
|
|
4
|
+
"appVersionSource": "remote"
|
|
5
|
+
},
|
|
6
|
+
"build": {
|
|
7
|
+
"development": {
|
|
8
|
+
"developmentClient": true,
|
|
9
|
+
"distribution": "internal",
|
|
10
|
+
"channel": "development",
|
|
11
|
+
"autoIncrement": true,
|
|
12
|
+
"env": {},
|
|
13
|
+
"cache": {
|
|
14
|
+
"paths": ["node_modules", "ios/Pods"]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"development:simulator": {
|
|
18
|
+
"extends": "development",
|
|
19
|
+
"env": {},
|
|
20
|
+
"ios": {
|
|
21
|
+
"simulator": true
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"development:device": {
|
|
25
|
+
"extends": "development",
|
|
26
|
+
"env": {},
|
|
27
|
+
"ios": {
|
|
28
|
+
"simulator": false
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"production": {
|
|
32
|
+
"autoIncrement": true,
|
|
33
|
+
"channel": "production",
|
|
34
|
+
"distribution": "store",
|
|
35
|
+
"env": {},
|
|
36
|
+
"cache": {
|
|
37
|
+
"paths": ["node_modules", "ios/Pods"]
|
|
38
|
+
},
|
|
39
|
+
"ios": {
|
|
40
|
+
"credentialsSource": "remote"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"submit": {
|
|
45
|
+
"testflight": {
|
|
46
|
+
"ios": {
|
|
47
|
+
"metadataPath": "./store.config.json"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"production": {
|
|
51
|
+
"ios": {
|
|
52
|
+
"metadataPath": "./store.config.json"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a value that lags the input by `delay` milliseconds. Useful for
|
|
5
|
+
* coalescing rapid changes (typing, scroll, etc.) before kicking off a more
|
|
6
|
+
* expensive operation downstream (filtering, fetching, rendering a big list).
|
|
7
|
+
*
|
|
8
|
+
* Each input change resets the timer, so the returned value only updates
|
|
9
|
+
* after `delay` ms have passed without further changes.
|
|
10
|
+
*/
|
|
11
|
+
export function useDebounce<T>(value: T, delay: number): T {
|
|
12
|
+
const [debounced, setDebounced] = useState(value);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const id = setTimeout(() => setDebounced(value), delay);
|
|
16
|
+
return () => clearTimeout(id);
|
|
17
|
+
}, [value, delay]);
|
|
18
|
+
|
|
19
|
+
return debounced;
|
|
20
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useURL } from "expo-linking";
|
|
3
|
+
import { router, type Href } from "expo-router";
|
|
4
|
+
|
|
5
|
+
import { authClient } from "@/lib/auth-client";
|
|
6
|
+
import { resolveDeepLink } from "@/lib/deep-link";
|
|
7
|
+
|
|
8
|
+
const ROUTES: Record<string, Href> = {
|
|
9
|
+
"/linked": "/linked" as Href,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Listens for deep link URLs and pushes to the matching route.
|
|
14
|
+
*
|
|
15
|
+
* Only runs once authenticated. Invalid or disallowed links are ignored.
|
|
16
|
+
* Query params are forwarded as route params.
|
|
17
|
+
*/
|
|
18
|
+
export function useDeepLinkHandler() {
|
|
19
|
+
// See note in app/_layout.tsx: Better Auth session is the canonical signal.
|
|
20
|
+
// `useConvexAuth` is unreliable due to the bridge's sessionId churn.
|
|
21
|
+
const { data: session } = authClient.useSession();
|
|
22
|
+
const isAuthenticated = !!session?.session;
|
|
23
|
+
const url = useURL();
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!isAuthenticated || !url) return;
|
|
27
|
+
|
|
28
|
+
let resolved;
|
|
29
|
+
try {
|
|
30
|
+
resolved = resolveDeepLink(url);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (__DEV__) console.warn("[DeepLink] parse failed:", err);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!resolved.path) return;
|
|
37
|
+
|
|
38
|
+
const target = ROUTES[resolved.path];
|
|
39
|
+
if (!target) return;
|
|
40
|
+
|
|
41
|
+
router.push({ pathname: target, params: resolved.params } as Href);
|
|
42
|
+
}, [isAuthenticated, url]);
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { usePathname, useSegments } from "expo-router";
|
|
3
|
+
|
|
4
|
+
export function useNavigationTracking() {
|
|
5
|
+
const pathname = usePathname();
|
|
6
|
+
const segments = useSegments();
|
|
7
|
+
const prev = useRef(pathname);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!__DEV__) return;
|
|
11
|
+
if (pathname === prev.current) return;
|
|
12
|
+
prev.current = pathname;
|
|
13
|
+
console.log("[Nav]", pathname, segments);
|
|
14
|
+
}, [pathname, segments]);
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useNetworkState } from "expo-network";
|
|
2
|
+
|
|
3
|
+
export function useNetwork() {
|
|
4
|
+
const { isConnected, isInternetReachable } = useNetworkState();
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
isConnected,
|
|
8
|
+
isInternetReachable,
|
|
9
|
+
isOffline: isConnected === false || isInternetReachable === false,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import * as Notifications from "expo-notifications";
|
|
3
|
+
import { useConvexAuth } from "convex/react";
|
|
4
|
+
import { useMutation } from "convex/react";
|
|
5
|
+
import { router } from "expo-router";
|
|
6
|
+
|
|
7
|
+
import { api } from "@/convex/_generated/api";
|
|
8
|
+
import { isValidDeepLink } from "@/lib/deep-link";
|
|
9
|
+
import {
|
|
10
|
+
getExpoPushToken,
|
|
11
|
+
requestPermission,
|
|
12
|
+
clearLastNotificationResponse,
|
|
13
|
+
} from "@/lib/notifications";
|
|
14
|
+
|
|
15
|
+
interface UseNotificationsOptions {
|
|
16
|
+
onNotificationReceived?: (notification: Notifications.Notification) => void;
|
|
17
|
+
onNotificationResponse?: (response: Notifications.NotificationResponse) => void;
|
|
18
|
+
onNotificationsDropped?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* If a notification's payload includes a `url` string, route to it after
|
|
23
|
+
* deep-link validation. Add custom action handling (categories, button taps,
|
|
24
|
+
* inline replies) here when your app needs it.
|
|
25
|
+
*/
|
|
26
|
+
function handleNotificationResponse(response: Notifications.NotificationResponse) {
|
|
27
|
+
const url = response.notification.request.content.data?.url;
|
|
28
|
+
if (typeof url !== "string") return;
|
|
29
|
+
|
|
30
|
+
if (isValidDeepLink(url)) {
|
|
31
|
+
router.push(url as Parameters<typeof router.push>[0]);
|
|
32
|
+
} else if (__DEV__) {
|
|
33
|
+
console.warn("[Notification] Blocked navigation to:", url);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useNotifications(options?: UseNotificationsOptions) {
|
|
38
|
+
const { isAuthenticated } = useConvexAuth();
|
|
39
|
+
const upsertToken = useMutation(api.pushTokens.upsert);
|
|
40
|
+
const registered = useRef(false);
|
|
41
|
+
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
|
|
42
|
+
|
|
43
|
+
// Register push token when authenticated
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!isAuthenticated) {
|
|
46
|
+
registered.current = false;
|
|
47
|
+
setExpoPushToken(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (registered.current) return;
|
|
51
|
+
registered.current = true;
|
|
52
|
+
|
|
53
|
+
(async () => {
|
|
54
|
+
const { granted } = await requestPermission();
|
|
55
|
+
if (!granted) return;
|
|
56
|
+
|
|
57
|
+
const token = await getExpoPushToken();
|
|
58
|
+
if (!token) return;
|
|
59
|
+
|
|
60
|
+
setExpoPushToken(token);
|
|
61
|
+
await upsertToken({ token, deviceType: "ios" });
|
|
62
|
+
})();
|
|
63
|
+
}, [isAuthenticated, upsertToken]);
|
|
64
|
+
|
|
65
|
+
// Event listeners
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const receivedSub = Notifications.addNotificationReceivedListener((notification) => {
|
|
68
|
+
if (__DEV__) console.log("[Notification] Received:", notification.request.identifier);
|
|
69
|
+
options?.onNotificationReceived?.(notification);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const responseSub = Notifications.addNotificationResponseReceivedListener((response) => {
|
|
73
|
+
if (__DEV__) console.log("[Notification] Response:", response.actionIdentifier);
|
|
74
|
+
handleNotificationResponse(response);
|
|
75
|
+
options?.onNotificationResponse?.(response);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const droppedSub = Notifications.addNotificationsDroppedListener(() => {
|
|
79
|
+
if (__DEV__) console.log("[Notification] Notifications dropped");
|
|
80
|
+
options?.onNotificationsDropped?.();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const tokenSub = Notifications.addPushTokenListener(async (token) => {
|
|
84
|
+
if (__DEV__) console.log("[Notification] Token rotated:", token.data);
|
|
85
|
+
if (isAuthenticated && typeof token.data === "string") {
|
|
86
|
+
await upsertToken({ token: token.data, deviceType: "ios" });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return () => {
|
|
91
|
+
receivedSub.remove();
|
|
92
|
+
responseSub.remove();
|
|
93
|
+
droppedSub.remove();
|
|
94
|
+
tokenSub.remove();
|
|
95
|
+
};
|
|
96
|
+
}, [isAuthenticated, options, upsertToken]);
|
|
97
|
+
|
|
98
|
+
// Cold-start deep linking
|
|
99
|
+
const lastResponse = Notifications.useLastNotificationResponse();
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!lastResponse) return;
|
|
102
|
+
handleNotificationResponse(lastResponse);
|
|
103
|
+
clearLastNotificationResponse();
|
|
104
|
+
}, [lastResponse]);
|
|
105
|
+
|
|
106
|
+
return { expoPushToken };
|
|
107
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "expo-sqlite/localStorage/install";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
const ONBOARDING_KEY = "onboarding_seen";
|
|
5
|
+
|
|
6
|
+
export function useOnboarding() {
|
|
7
|
+
const [seen, setSeen] = useState<boolean>(localStorage.getItem(ONBOARDING_KEY) === "true");
|
|
8
|
+
|
|
9
|
+
const markSeen = () => {
|
|
10
|
+
localStorage.setItem(ONBOARDING_KEY, "true");
|
|
11
|
+
setSeen(true);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return { seen, markSeen };
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useReducedMotion as useSystemReducedMotion } from "react-native-reanimated";
|
|
2
|
+
|
|
3
|
+
import { useReduceMotionPref } from "@/lib/preferences";
|
|
4
|
+
|
|
5
|
+
export function useReducedMotion(): boolean {
|
|
6
|
+
const [pref] = useReduceMotionPref();
|
|
7
|
+
const systemOn = useSystemReducedMotion();
|
|
8
|
+
if (pref === "always") return true;
|
|
9
|
+
if (pref === "never") return false;
|
|
10
|
+
return systemOn;
|
|
11
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { Appearance, useColorScheme as useRNColorScheme } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { createStorage } from "@/lib/storage";
|
|
5
|
+
import { Colors, type ColorPalette } from "@/constants/theme";
|
|
6
|
+
|
|
7
|
+
export type ThemeMode = "light" | "dark" | "system";
|
|
8
|
+
|
|
9
|
+
const store = createStorage<ThemeMode>("pref.theme.mode", "system");
|
|
10
|
+
|
|
11
|
+
function applyToWindow(mode: ThemeMode) {
|
|
12
|
+
Appearance.setColorScheme(mode === "system" ? "unspecified" : mode);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
applyToWindow(store.get());
|
|
16
|
+
|
|
17
|
+
export function setTheme(mode: ThemeMode) {
|
|
18
|
+
store.set(mode);
|
|
19
|
+
applyToWindow(mode);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getTheme(): ThemeMode {
|
|
23
|
+
return store.get();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useColorScheme(): "light" | "dark" {
|
|
27
|
+
const mode = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
28
|
+
const systemScheme = useRNColorScheme();
|
|
29
|
+
if (mode === "system") return systemScheme === "dark" ? "dark" : "light";
|
|
30
|
+
return mode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useThemeMode(): {
|
|
34
|
+
mode: ThemeMode;
|
|
35
|
+
setMode: (mode: ThemeMode) => void;
|
|
36
|
+
} {
|
|
37
|
+
const mode = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
38
|
+
return { mode, setMode: setTheme };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useColors(): ColorPalette {
|
|
42
|
+
return Colors;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Theme-aware asset selector. Pass the light variant first, dark second.
|
|
46
|
+
// Returns whichever matches the active appearance (which honors the in-app
|
|
47
|
+
// override from `setTheme` in addition to the system setting).
|
|
48
|
+
//
|
|
49
|
+
// const icon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
|
|
50
|
+
export function useThemedAsset<L, D>(light: L, dark: D): L | D {
|
|
51
|
+
const scheme = useColorScheme();
|
|
52
|
+
return scheme === "dark" ? dark : light;
|
|
53
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useUpdates,
|
|
5
|
+
isEnabled,
|
|
6
|
+
checkForUpdate as checkForUpdateFn,
|
|
7
|
+
fetchUpdate,
|
|
8
|
+
reload,
|
|
9
|
+
buildReloadScreenConfig,
|
|
10
|
+
} from "@/lib/updates";
|
|
11
|
+
import { haptics } from "@/lib/haptics";
|
|
12
|
+
import { useColorScheme } from "@/hooks/use-theme";
|
|
13
|
+
import { useReducedMotion } from "@/hooks/use-reduced-motion";
|
|
14
|
+
|
|
15
|
+
type UpdatesState = ReturnType<typeof useUpdates>;
|
|
16
|
+
|
|
17
|
+
function deriveStatusText(state: UpdatesState): string {
|
|
18
|
+
if (state.isRestarting) return "Restarting...";
|
|
19
|
+
if (state.isUpdatePending) return "Restarting...";
|
|
20
|
+
if (state.isDownloading) {
|
|
21
|
+
const pct =
|
|
22
|
+
state.downloadProgress != null ? ` ${Math.round(state.downloadProgress * 100)}%` : "";
|
|
23
|
+
return `Downloading...${pct}`;
|
|
24
|
+
}
|
|
25
|
+
if (state.isChecking) return "Checking...";
|
|
26
|
+
if (state.downloadError) return state.downloadError.message;
|
|
27
|
+
if (state.checkError) return state.checkError.message;
|
|
28
|
+
if (state.isUpdateAvailable) return "Update available";
|
|
29
|
+
return "Up to date";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const NOOP_STATE: UpdatesState = {
|
|
33
|
+
currentlyRunning: {
|
|
34
|
+
isEmbeddedLaunch: true,
|
|
35
|
+
isEmergencyLaunch: false,
|
|
36
|
+
emergencyLaunchReason: null,
|
|
37
|
+
},
|
|
38
|
+
isStartupProcedureRunning: false,
|
|
39
|
+
isUpdateAvailable: false,
|
|
40
|
+
isUpdatePending: false,
|
|
41
|
+
isChecking: false,
|
|
42
|
+
isDownloading: false,
|
|
43
|
+
isRestarting: false,
|
|
44
|
+
restartCount: 0,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function useUpdatesImpl(): UpdatesState {
|
|
48
|
+
const enabled = isEnabled && !__DEV__;
|
|
49
|
+
const state = useUpdates();
|
|
50
|
+
const scheme = useColorScheme();
|
|
51
|
+
const reduceMotion = useReducedMotion();
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!enabled) return;
|
|
55
|
+
if (state.isUpdatePending) {
|
|
56
|
+
reload({ reloadScreenOptions: buildReloadScreenConfig(scheme, reduceMotion) });
|
|
57
|
+
}
|
|
58
|
+
}, [enabled, state.isUpdatePending, scheme, reduceMotion]);
|
|
59
|
+
|
|
60
|
+
return enabled ? state : NOOP_STATE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useAppUpdates() {
|
|
64
|
+
const state = useUpdatesImpl();
|
|
65
|
+
|
|
66
|
+
const checkForUpdate = () => {
|
|
67
|
+
if (state.isChecking) return;
|
|
68
|
+
haptics.light();
|
|
69
|
+
checkForUpdateFn();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const downloadAndApply = () => {
|
|
73
|
+
if (state.isDownloading) return;
|
|
74
|
+
haptics.light();
|
|
75
|
+
fetchUpdate();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const statusText = deriveStatusText(state);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
checkForUpdate,
|
|
83
|
+
downloadAndApply,
|
|
84
|
+
statusText,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { reloadAppAsync as _reloadAppAsync } from "expo";
|
|
2
|
+
|
|
3
|
+
export { executionEnvironment } from "./device";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Force-reload the app using the current JS bundle.
|
|
7
|
+
*
|
|
8
|
+
* Unlike `Updates.reloadAsync()`, this does NOT fetch or apply a new update.
|
|
9
|
+
* It simply restarts the JS runtime with the same bundle.
|
|
10
|
+
*
|
|
11
|
+
* Use for: auth state corruption, unrecoverable cache errors, language changes
|
|
12
|
+
* that require a full restart, or a manual "restart app" button in settings.
|
|
13
|
+
*/
|
|
14
|
+
export const reloadApp = _reloadAppAsync;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Runtime asset registry. The five files referenced below live in ./assets/.
|
|
2
|
+
// To rebrand, replace the PNGs in place with your own renders at the same
|
|
3
|
+
// dimensions and file names. See DESIGN.md for the surface specs.
|
|
4
|
+
//
|
|
5
|
+
// Surfaces:
|
|
6
|
+
// icon iOS bundle icon, 1024x1024 (iOS rounds the corners)
|
|
7
|
+
// brandIcon* in-app chiclet (welcome, sign-in, sign-up, loading)
|
|
8
|
+
// splash* expo-splash-screen image, sits on configured bg color
|
|
9
|
+
export const assets = {
|
|
10
|
+
icon: require("@/assets/icon.png"),
|
|
11
|
+
brandIconLight: require("@/assets/brand-icon-light.png"),
|
|
12
|
+
brandIconDark: require("@/assets/brand-icon-dark.png"),
|
|
13
|
+
splashLight: require("@/assets/splash-image-light.png"),
|
|
14
|
+
splashDark: require("@/assets/splash-image-dark.png"),
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export const assetModules = Object.values(assets);
|