fix(waitlist): queue + retry failed signups instead of silently opening mailto (#165)
A signup that hit a network error, the 8s timeout or a 5xx was handed straight
to a `mailto:` composer. That path is lossy by design: it only works if the user
actually presses send, and if we keep reconciling the support inbox into Brevo
list 4 forever (AGE-61's hourly job). 20 of 21 signups were lost that way before
that reconciler existed, and Play's active base is ~100% on v0.4.10+ — so this
was current builds leaking, not just the ~436 stale sideloads.
Now:
- Failed-but-retryable signups are persisted on-device
(`opencode.waitlist.pending.v1`, AsyncStorage) and retried on every app
foreground (`app/_layout.tsx`) and on the Add Connection screen mount.
- 4xx stays non-retryable: the server will never accept that address, so we ask
the user to fix it instead of queueing garbage forever.
- `mailto:` is now only ever opened by an explicit user tap ("Still not working?
Email us instead"), shown after 3 failed attempts, or offered in an alert when
device storage itself refuses the write — never as the silent default.
- The UI tells the truth: "Saved on this device — we'll finish signing you up as
soon as you're back online" instead of implying it was sent.
- `WaitlistResult.fallback` -> `retryable`, `shouldFallbackToMailto` ->
`isRetryableFailure`: the decision is about retry, not about mail.
Queue policy: dedupe by email, cap 5 entries, 30-day TTL, corrupt/foreign JSON
is discarded rather than replayed. Storage and the clock are injected so the
whole thing runs under `node --test` (16 new tests, incl. the acceptance case:
offline signup -> queued -> reconnect -> reaches the server, no mail client).
Also commits the AGE-61 measurement artifacts that were only ever local
(`distribution/waitlist-signup-path-coverage.md`, `scripts/play-version-share.mjs`)
and updates the doc's "current builds still leak" section, which this fixes.
Refs AGE-87, AGE-61.
Co-authored-by: engineer <engineer@macbookpro.lan>
This commit is contained in:
@@ -19,6 +19,7 @@ import * as notifications from "../src/lib/notifications"
|
||||
import { addBreadcrumb, wrap } from "../src/lib/sentry"
|
||||
import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry"
|
||||
import { initAnalytics, trackAppOpened } from "../src/lib/analytics"
|
||||
import { flushPendingSignups } from "../src/lib/waitlist-queue-storage"
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
@@ -95,6 +96,30 @@ function RootLayout() {
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
// Retry any waitlist signup that couldn't reach the server when the user
|
||||
// tapped Join (AGE-87). Runs at cold start and on every foreground, which is
|
||||
// the cheapest reliable proxy for "connectivity may have come back" — it is a
|
||||
// no-op (single storage read, no network) when the queue is empty, and it
|
||||
// replaces the old silent mailto: fallback that lost 20 of 21 signups.
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
void flushPendingSignups()
|
||||
.then((outcome) => {
|
||||
if (outcome.synced.length > 0) {
|
||||
addBreadcrumb({ category: "waitlist", message: `retried ${outcome.synced.length} queued signup(s)` })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Best effort: the entry stays queued for the next foreground.
|
||||
})
|
||||
}
|
||||
flush()
|
||||
const sub = AppState.addEventListener("change", (next) => {
|
||||
if (next === "active") flush()
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
// Connect/disconnect SSE and load catalog when client changes
|
||||
useEffect(() => {
|
||||
if (client && !sseStarted.current) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -21,7 +21,8 @@ import { captureDiagnostic } from "../../src/lib/sentry"
|
||||
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
||||
import { buildAuth } from "../../src/lib/auth"
|
||||
import { AnalyticsEvent, track } from "../../src/lib/analytics"
|
||||
import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/waitlist"
|
||||
import { submitWaitlistSignup, buildWaitlistMailtoUrl, needsManualEscapeHatch, type QueuedSignup } from "../../src/lib/waitlist"
|
||||
import { flushPendingSignups, queuePendingSignup, readPendingSignups, dropPendingSignup } from "../../src/lib/waitlist-queue-storage"
|
||||
|
||||
export default function AddConnectionScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
@@ -41,7 +42,34 @@ 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")
|
||||
// "queued" = the POST failed but the signup is persisted on-device and will
|
||||
// be retried on the next foreground/connectivity (AGE-87). It is NOT "sent".
|
||||
const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined" | "queued">("idle")
|
||||
const [pendingSignup, setPendingSignup] = useState<QueuedSignup | null>(null)
|
||||
|
||||
// Retry anything left over from a previous session as soon as this screen
|
||||
// opens (the root layout also flushes on every foreground), then reflect the
|
||||
// real state back to the user instead of pretending nothing is pending.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const outcome = await flushPendingSignups().catch(() => null)
|
||||
if (cancelled) return
|
||||
const pending = outcome ? outcome.pending : await readPendingSignups().catch(() => [])
|
||||
if (cancelled) return
|
||||
if (outcome && outcome.synced.length > 0 && pending.length === 0) {
|
||||
setWaitlistState("joined")
|
||||
return
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
setPendingSignup(pending[pending.length - 1])
|
||||
setWaitlistState((current) => (current === "idle" ? "queued" : current))
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const buildUrl = () => {
|
||||
if (mode === "advanced") return url.trim()
|
||||
@@ -213,25 +241,70 @@ export default function AddConnectionScreen() {
|
||||
|
||||
const handleJoinWaitlist = async () => {
|
||||
if (waitlistState === "submitting") return
|
||||
const attemptedEmail = waitlistEmail
|
||||
setWaitlistState("submitting")
|
||||
const result = await submitWaitlistSignup(waitlistEmail)
|
||||
const result = await submitWaitlistSignup(attemptedEmail)
|
||||
if (result.ok) {
|
||||
// Clear any earlier queued attempt for the same address so the flush
|
||||
// doesn't re-post it.
|
||||
void dropPendingSignup(result.email)
|
||||
setPendingSignup(null)
|
||||
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(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.fallbackMessage"))
|
||||
}
|
||||
} else {
|
||||
|
||||
if (!result.retryable) {
|
||||
// The server rejected this address; queueing it would retry forever.
|
||||
setWaitlistState(pendingSignup ? "queued" : "idle")
|
||||
Alert.alert(t("connection.add.waitlist.alertTitle"), result.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Offline / timeout / 5xx: persist and retry later instead of dumping the
|
||||
// user into a mail composer they may never send (AGE-87).
|
||||
const entry = await queuePendingSignup(result.email, result.error)
|
||||
if (entry) {
|
||||
setPendingSignup(entry)
|
||||
setWaitlistState("queued")
|
||||
return
|
||||
}
|
||||
|
||||
// Storage refused the write — we cannot promise to finish this later, so
|
||||
// offer the manual email path explicitly rather than claiming success.
|
||||
setWaitlistState("idle")
|
||||
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.queueFailedMessage"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: t("connection.add.waitlist.emailUsButton"), onPress: () => void openWaitlistMailto(result.email) },
|
||||
])
|
||||
}
|
||||
|
||||
// Last-resort, user-initiated only. Never opened automatically.
|
||||
const openWaitlistMailto = async (email: string) => {
|
||||
try {
|
||||
await Linking.openURL(buildWaitlistMailtoUrl(email))
|
||||
} catch {
|
||||
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.noMailAppMessage"))
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit "Retry" from the queued state — same code path the foreground
|
||||
// flush uses, so there is only one retry implementation.
|
||||
const handleRetryQueued = async () => {
|
||||
setWaitlistState("submitting")
|
||||
const outcome = await flushPendingSignups().catch(() => null)
|
||||
if (outcome && outcome.pending.length === 0 && outcome.synced.length > 0) {
|
||||
setPendingSignup(null)
|
||||
setWaitlistState("joined")
|
||||
return
|
||||
}
|
||||
if (outcome && outcome.pending.length === 0) {
|
||||
// Nothing left pending and nothing synced: the address was rejected.
|
||||
setPendingSignup(null)
|
||||
setWaitlistState("idle")
|
||||
return
|
||||
}
|
||||
if (outcome) setPendingSignup(outcome.pending[outcome.pending.length - 1])
|
||||
setWaitlistState("queued")
|
||||
}
|
||||
|
||||
// Quick connect mode - simplified
|
||||
@@ -376,6 +449,27 @@ export default function AddConnectionScreen() {
|
||||
{t("connection.add.waitlist.successText")}
|
||||
</Text>
|
||||
</View>
|
||||
) : waitlistState === "queued" ? (
|
||||
<View testID="waitlist-queued">
|
||||
<View style={styles.waitlistSuccess}>
|
||||
<Ionicons name="time-outline" size={20} color="#f59e0b" />
|
||||
<Text style={[styles.waitlistSuccessText, isDark && styles.textDark]}>
|
||||
{t("connection.add.waitlist.queuedText")}
|
||||
</Text>
|
||||
</View>
|
||||
{needsManualEscapeHatch(pendingSignup) && (
|
||||
<TouchableOpacity
|
||||
style={styles.waitlistEscapeHatch}
|
||||
onPress={() => void openWaitlistMailto(pendingSignup?.email ?? waitlistEmail)}
|
||||
testID="waitlist-email-us"
|
||||
>
|
||||
<Text style={styles.waitlistEscapeHatchText}>{t("connection.add.waitlist.emailUsLink")}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.waitlistEscapeHatch} onPress={() => void handleRetryQueued()} testID="waitlist-retry">
|
||||
<Text style={styles.waitlistEscapeHatchText}>{t("common.retry")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
@@ -834,4 +928,13 @@ const styles = StyleSheet.create({
|
||||
color: "#0a0a0a",
|
||||
lineHeight: 20,
|
||||
},
|
||||
waitlistEscapeHatch: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
waitlistEscapeHatchText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#6366f1",
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user