diff --git a/app/connection/add.tsx b/app/connection/add.tsx
index b44bd04..ba53492 100644
--- a/app/connection/add.tsx
+++ b/app/connection/add.tsx
@@ -19,6 +19,7 @@ import { probeConnection, shareReport } from "../../src/lib/diagnostics"
import { captureDiagnostic } from "../../src/lib/sentry"
import { parseUrl } from "../../src/lib/diagnostics-classify"
import { AnalyticsEvent, track } from "../../src/lib/analytics"
+import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/waitlist"
export default function AddConnectionScreen() {
const colorScheme = useColorScheme()
@@ -37,6 +38,7 @@ export default function AddConnectionScreen() {
const [password, setPassword] = useState("")
const [isConnecting, setIsConnecting] = useState(false)
const [waitlistEmail, setWaitlistEmail] = useState("")
+ const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined">("idle")
const buildUrl = () => {
if (mode === "advanced") return url.trim()
@@ -150,11 +152,27 @@ export default function AddConnectionScreen() {
router.back()
}
- const handleJoinWaitlist = () => {
- const email = waitlistEmail.trim()
- const subject = encodeURIComponent("OpenCode Connect Waitlist")
- const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!")
- Linking.openURL(`mailto:support@agentlabs.cc?subject=${subject}&body=${body}`)
+ const handleJoinWaitlist = async () => {
+ if (waitlistState === "submitting") return
+ setWaitlistState("submitting")
+ const result = await submitWaitlistSignup(waitlistEmail)
+ if (result.ok) {
+ setWaitlistState("joined")
+ return
+ }
+ setWaitlistState("idle")
+ if (result.fallback) {
+ // API unreachable/broken: fall back to the pre-#87 mailto path so the
+ // signup still reaches the support inbox instead of being lost.
+ try {
+ await Linking.openURL(buildWaitlistMailtoUrl(result.email))
+ } catch {
+ // No mail app either — tell the user instead of failing silently.
+ Alert.alert("Join Waitlist", "Could not reach the signup service or open an email app. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\".")
+ }
+ } else {
+ Alert.alert("Join Waitlist", result.error)
+ }
}
// Quick connect mode - simplified
@@ -283,23 +301,44 @@ export default function AddConnectionScreen() {
Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from
anywhere.
-
-
-
- Join Waitlist
-
+ {waitlistState === "joined" ? (
+
+
+
+ You're on the list — we'll email you when OpenCode Connect is ready.
+
+
+ ) : (
+ <>
+
+
+ {waitlistState === "submitting" ? (
+
+ ) : (
+ <>
+
+ Join Waitlist
+ >
+ )}
+
+ >
+ )}
{/* Advanced mode link */}
@@ -711,4 +750,16 @@ const styles = StyleSheet.create({
fontWeight: "600",
color: "#ffffff",
},
+ waitlistSuccess: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 12,
+ },
+ waitlistSuccessText: {
+ flex: 1,
+ fontSize: 13,
+ color: "#0a0a0a",
+ lineHeight: 20,
+ },
})
diff --git a/src/lib/waitlist.test.ts b/src/lib/waitlist.test.ts
new file mode 100644
index 0000000..5cd4ec6
--- /dev/null
+++ b/src/lib/waitlist.test.ts
@@ -0,0 +1,148 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import {
+ WAITLIST_ENDPOINT,
+ WAITLIST_SOURCE,
+ normalizeWaitlistEmail,
+ buildWaitlistPayload,
+ buildWaitlistMailtoUrl,
+ shouldFallbackToMailto,
+ submitWaitlistSignup,
+} from "./waitlist.ts"
+
+// --- normalizeWaitlistEmail: mirror of the server's 400 rule ---
+
+test("normalize trims and lowercases like the server", () => {
+ assert.equal(normalizeWaitlistEmail(" Dev@Example.COM "), "dev@example.com")
+})
+
+test("normalize rejects what the server would 400 on", () => {
+ assert.equal(normalizeWaitlistEmail(""), null)
+ assert.equal(normalizeWaitlistEmail(" "), null)
+ assert.equal(normalizeWaitlistEmail("not-an-email"), null)
+ assert.equal(normalizeWaitlistEmail("a b@example.com"), null)
+ assert.equal(normalizeWaitlistEmail("no-tld@host"), null)
+ assert.equal(normalizeWaitlistEmail(`${"x".repeat(250)}@a.com`), null) // > 254 chars
+})
+
+// --- payload: the source tag is the whole point of #87 ---
+
+test("payload tags the signup with the opencode-connect source", () => {
+ assert.deepEqual(buildWaitlistPayload("dev@example.com"), {
+ email: "dev@example.com",
+ source: WAITLIST_SOURCE,
+ })
+})
+
+// --- mailto fallback URL: byte-compatible with the pre-#87 behavior ---
+
+test("mailto fallback preserves subject and embeds the email", () => {
+ const url = buildWaitlistMailtoUrl("dev@example.com")
+ assert.ok(url.startsWith("mailto:support@agentlabs.cc?"))
+ assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist"))
+ assert.ok(url.includes(encodeURIComponent("Email: dev@example.com")))
+})
+
+test("mailto fallback works without an email", () => {
+ const url = buildWaitlistMailtoUrl("")
+ assert.ok(url.includes("body=Sign%20me%20up!"))
+ assert.ok(!url.includes("Email"))
+})
+
+// --- fallback decision ---
+
+// 502 named explicitly: the server returns it when Brevo itself fails, and the
+// signup must survive that via mailto.
+test("fallback: transport failures and 5xx (incl. 502 Brevo failure) -> mailto, 4xx -> fix input", () => {
+ assert.equal(shouldFallbackToMailto({ kind: "network-error" }), true)
+ assert.equal(shouldFallbackToMailto({ kind: "http", status: 500 }), true)
+ assert.equal(shouldFallbackToMailto({ kind: "http", status: 502 }), true)
+ assert.equal(shouldFallbackToMailto({ kind: "http", status: 503 }), true)
+ assert.equal(shouldFallbackToMailto({ kind: "http", status: 400 }), false)
+ assert.equal(shouldFallbackToMailto({ kind: "http", status: 429 }), false)
+})
+
+// --- submitWaitlistSignup with injected fetch ---
+
+type FetchCall = { url: string; init: { method: string; headers: Record; body: string; signal: AbortSignal } }
+
+function fakeFetch(response: { ok: boolean; status: number; body?: unknown }, calls: FetchCall[] = []) {
+ return async (url: string, init: FetchCall["init"]) => {
+ calls.push({ url, init })
+ return { ok: response.ok, status: response.status, json: async () => response.body ?? null }
+ }
+}
+
+test("submit posts the tagged payload to the beta-signup endpoint", async () => {
+ const calls: FetchCall[] = []
+ const result = await submitWaitlistSignup(" Dev@Example.com ", { fetchFn: fakeFetch({ ok: true, status: 200, body: { ok: true } }, calls) })
+ assert.deepEqual(result, { ok: true, email: "dev@example.com" })
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].url, WAITLIST_ENDPOINT)
+ assert.equal(calls[0].init.method, "POST")
+ assert.equal(calls[0].init.headers["content-type"], "application/json")
+ assert.deepEqual(JSON.parse(calls[0].init.body), { email: "dev@example.com", source: WAITLIST_SOURCE })
+ assert.ok(calls[0].init.signal instanceof AbortSignal)
+})
+
+test("submit rejects an invalid email locally without hitting the network", async () => {
+ const calls: FetchCall[] = []
+ const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) })
+ assert.equal(calls.length, 0)
+ assert.deepEqual(result, { ok: false, email: "nope", fallback: false, error: "Enter a valid email address." })
+})
+
+test("submit surfaces the server's 400 message without falling back to mailto", async () => {
+ const result = await submitWaitlistSignup("dev@example.com", {
+ fetchFn: fakeFetch({ ok: false, status: 400, body: { error: "Enter a valid email address." } }),
+ })
+ assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: false, error: "Enter a valid email address." })
+})
+
+test("submit falls back to mailto on 5xx (server broken, keep the signup alive)", async () => {
+ const result = await submitWaitlistSignup("dev@example.com", {
+ fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }),
+ })
+ assert.equal(result.ok, false)
+ if (!result.ok) {
+ assert.equal(result.fallback, true)
+ assert.equal(result.error, "The waitlist is temporarily unavailable. Please try again later.")
+ }
+})
+
+test("submit falls back to mailto when fetch rejects (offline)", async () => {
+ const result = await submitWaitlistSignup("dev@example.com", {
+ fetchFn: async () => {
+ throw new TypeError("Network request failed")
+ },
+ })
+ assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Network request failed" })
+})
+
+test("submit aborts after timeoutMs and falls back to mailto", async () => {
+ const result = await submitWaitlistSignup("dev@example.com", {
+ timeoutMs: 20,
+ fetchFn: (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init.signal.addEventListener("abort", () => {
+ const err = new Error("Aborted")
+ err.name = "AbortError"
+ reject(err)
+ })
+ }),
+ })
+ assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "timeout after 20ms" })
+})
+
+test("submit tolerates a non-JSON error body", async () => {
+ const result = await submitWaitlistSignup("dev@example.com", {
+ fetchFn: async () => ({
+ ok: false,
+ status: 502,
+ json: async () => {
+ throw new SyntaxError("Unexpected token")
+ },
+ }),
+ })
+ assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Signup failed (HTTP 502)." })
+})
diff --git a/src/lib/waitlist.ts b/src/lib/waitlist.ts
new file mode 100644
index 0000000..61a75f5
--- /dev/null
+++ b/src/lib/waitlist.ts
@@ -0,0 +1,117 @@
+// OpenCode Connect waitlist signup: pure payload/fallback logic.
+//
+// Kept free of react-native imports (Linking, Alert) so it's unit-testable
+// with plain `node --test`, the same split used for buildAuth() in auth.ts
+// and shouldRequestReview() in store-review-policy.ts. The screen injects
+// nothing in production (global fetch is used); tests inject a fake fetch.
+//
+// Backend: the OpenCodeMobileSite beta-signup route (VibeBrowserProductPage
+// repo, OpenCodeMobileSite/app/api/beta-signup/route.ts) validates `email`
+// and adds it to a Brevo list. It ignores unknown body fields today, so the
+// `source` tag we send is forward-compatible: harmless now, attributable as
+// soon as the route starts reading it.
+
+export const WAITLIST_ENDPOINT = "https://opencode.agentlabs.cc/api/beta-signup"
+export const WAITLIST_SOURCE = "opencode-connect-waitlist"
+export const WAITLIST_TIMEOUT_MS = 8_000
+export const WAITLIST_FALLBACK_EMAIL = "support@agentlabs.cc"
+
+// Mirrors the server-side pattern in brevo-contact.ts so we reject locally
+// exactly what the server would 400 on, instead of burning a round trip.
+const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+/** Trim/lowercase like the server does; null when the server would 400. */
+export function normalizeWaitlistEmail(raw: string): string | null {
+ const email = raw.trim().toLowerCase()
+ if (!email || email.length > 254 || !emailPattern.test(email)) return null
+ return email
+}
+
+export function buildWaitlistPayload(email: string): { email: string; source: string } {
+ return { email, source: WAITLIST_SOURCE }
+}
+
+/** The pre-#87 mailto path, kept as the fallback when the API is unreachable. */
+export function buildWaitlistMailtoUrl(email: string): string {
+ const subject = encodeURIComponent("OpenCode Connect Waitlist")
+ const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!")
+ return `mailto:${WAITLIST_FALLBACK_EMAIL}?subject=${subject}&body=${body}`
+}
+
+export type WaitlistResult =
+ /** Signup persisted server-side. */
+ | { ok: true; email: string }
+ /** Signup not persisted. `fallback` decides the UX: true -> open the
+ * mailto fallback so the signup still reaches the support inbox;
+ * false -> the input (or server validation) is wrong, ask the user to fix
+ * their email instead of mailing garbage. */
+ | { ok: false; email: string; fallback: boolean; error: string }
+
+/**
+ * Fallback decision, isolated for testability:
+ * - transport failure (offline, DNS, timeout) -> mailto keeps the signup alive
+ * - 5xx -> server broken through no fault of the user's -> mailto
+ * - 4xx -> the server rejected this email; mailing it wouldn't help
+ */
+export function shouldFallbackToMailto(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean {
+ if (outcome.kind === "network-error") return true
+ return outcome.status >= 500
+}
+
+type FetchLike = (
+ url: string,
+ init: { method: string; headers: Record; body: string; signal: AbortSignal },
+) => Promise<{ ok: boolean; status: number; json(): Promise }>
+
+export interface WaitlistDeps {
+ fetchFn?: FetchLike
+ timeoutMs?: number
+}
+
+function serverError(body: unknown): string | undefined {
+ if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string") {
+ return body.error
+ }
+ return undefined
+}
+
+export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise {
+ const email = normalizeWaitlistEmail(rawEmail)
+ if (email === null) {
+ return { ok: false, email: rawEmail.trim(), fallback: false, error: "Enter a valid email address." }
+ }
+
+ const fetchFn = deps.fetchFn ?? (fetch as unknown as FetchLike)
+ const timeoutMs = deps.timeoutMs ?? WAITLIST_TIMEOUT_MS
+
+ // Same timeout pattern as timedFetch() in diagnostics.ts.
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
+ try {
+ const res = await fetchFn(WAITLIST_ENDPOINT, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(buildWaitlistPayload(email)),
+ signal: controller.signal,
+ })
+ if (res.ok) return { ok: true, email }
+ const message = serverError(await res.json().catch(() => null))
+ return {
+ ok: false,
+ email,
+ fallback: shouldFallbackToMailto({ kind: "http", status: res.status }),
+ error: message || `Signup failed (HTTP ${res.status}).`,
+ }
+ } catch (error: unknown) {
+ const err = error as { name?: string; message?: string }
+ const aborted = err?.name === "AbortError"
+ return {
+ ok: false,
+ email,
+ fallback: shouldFallbackToMailto({ kind: "network-error" }),
+ error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error),
+ }
+ } finally {
+ clearTimeout(timer)
+ }
+}