chore(launch): reconcile README/store live-status + add demo-funnel analytics (#111)

Two scoped changes for the no-spend growth launch (Growth Launch Kit,
Notion page 3a1ac25eb49f81099cc9f3a4286c8ec4):

1. README.md and distribution/play-listing.md said Google Play was
   "coming soon" / internal-testing-only, while distribution/retention-analysis.md
   and the live play.google.com listing show it's actually public with 1K+
   installs. Fixed the contradiction, added Google Play as a third install
   channel, and added an accurate mention of the new offline demo mode
   ("Try a Demo" — reasoning, grep, diff, permission prompt, ~30s, no server)
   matching what app/demo.tsx + src/lib/demo-script.ts actually render.
   play-listing.md's stale pre-launch checklists are marked historical
   instead of rewritten, so #83's ASO copy/keyword work is untouched.

2. Added the demo funnel's key metric (demo-completion, per the launch
   kit) as four consent-gated PostHog events: demo_started,
   demo_step_advanced, demo_completed, demo_exited_to_connect. Pure
   property-derivation logic lives in src/lib/demo-analytics.ts (no
   RN/PostHog imports, unit-tested with node --test, same pattern as
   analytics-classify.ts) and is wired into app/demo.tsx's lifecycle.
   Updated docs/analytics.md's event table and the privacy policy's event
   list (distribution/privacy-policy.md + its two HTML mirrors) per the
   repo's "new event requires a policy update" convention.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Den
2026-07-17 19:49:47 -07:00
committed by GitHub
parent c9ec92d8b7
commit 1ea84f8236
10 changed files with 220 additions and 37 deletions

View File

@@ -1,12 +1,12 @@
# OpenCode Mobile
**The open-source Android client for the [opencode](https://github.com/sst/opencode) AI coding agent.**
AI-assisted coding from your phone — Android, via F-Droid or a direct APK.
AI-assisted coding from your phone — Android, via Google Play, F-Droid, or a direct APK.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![F-Droid repo](https://img.shields.io/badge/F--Droid-add_our_repo-1976D2?logo=f-droid)](https://dzianisv.github.io/opencode-mobile/fdroid/repo)
[![Download APK](https://img.shields.io/badge/Download-APK-green?logo=android)](https://github.com/dzianisv/opencode-mobile/releases/latest)
[![Google Play](https://img.shields.io/badge/Google_Play-coming_soon-lightgrey?logo=google-play)](#)
[![Google Play](https://img.shields.io/badge/Google_Play-Available-4CAF50?logo=google-play)](https://play.google.com/store/apps/details?id=cc.agentlabs.opencode)
> **Not affiliated with opencode.** OpenCode Mobile is an independent, community-built client and is
> not made by, endorsed by, or affiliated with the opencode / Anomaly team. It talks to an opencode
@@ -14,20 +14,26 @@ AI-assisted coding from your phone — Android, via F-Droid or a direct APK.
---
**New: tap "Try a Demo" in the app to see the agent fix a real bug — reasoning, a grep, a diff, a permission prompt — in about 30 seconds, no server needed.**
---
## Install (Android)
There are **two working ways** to install OpenCode Mobile today, both for Android:
There are **three working ways** to install OpenCode Mobile today, all for Android:
1. **F-Droid (recommended)** — add our self-hosted repo to any F-Droid client, then install/update from there:
1. **Google Play** — **https://play.google.com/store/apps/details?id=cc.agentlabs.opencode**
2. **F-Droid (self-hosted repo)** — add our self-hosted repo to any F-Droid client, then install/update from there:
```
https://dzianisv.github.io/opencode-mobile/fdroid/repo
```
In the F-Droid app: **Settings → Repositories → + (add)** and paste the URL above. Current version: **v0.4.3**.
2. **Direct signed APK** — download the latest release and install it manually:
3. **Direct signed APK** — download the latest release and install it manually:
**https://github.com/dzianisv/opencode-mobile/releases/latest**
> iOS is not available (see [Roadmap](#roadmap)). Google Play is in internal testing only (no public listing yet). IzzyOnDroid submission is pending.
> iOS is not available (see [Roadmap](#roadmap)). IzzyOnDroid submission is pending.
---
@@ -45,6 +51,7 @@ OpenCode Mobile is a React Native / Expo app that brings the power of the [openc
## Features
- **Offline demo mode** — tap "Try a Demo" to see a full bug-fix walkthrough (reasoning → grep → diff → permission prompt) with zero setup, right from the empty state
- **Multi-connection** — manage multiple opencode servers (local network, Cloudflare Tunnel, ngrok, or Tailscale)
- **Biometric unlock** — Face ID, Touch ID, or Android fingerprint protects the app and individual message sends
- **Streaming chat** — token-by-token streaming responses directly from your opencode server
@@ -61,18 +68,20 @@ Package: `cc.agentlabs.opencode` · Android only · current version v0.4.3
| Channel | Status | How |
|---|---|---|
| **Google Play** | **Live** | [play.google.com/store/apps/details?id=cc.agentlabs.opencode](https://play.google.com/store/apps/details?id=cc.agentlabs.opencode) |
| **F-Droid (self-hosted repo)** | **Live** | Add [`https://dzianisv.github.io/opencode-mobile/fdroid/repo`](https://dzianisv.github.io/opencode-mobile/fdroid/repo) in your F-Droid client |
| **Direct APK** | **Live** | [github.com/dzianisv/opencode-mobile/releases/latest](https://github.com/dzianisv/opencode-mobile/releases/latest) |
| Google Play | Internal testing / coming soon | No public listing yet |
| IzzyOnDroid | Submission pending | Not live yet |
| Apple App Store / iOS | Not available | See [Roadmap](#roadmap) |
> The two live, supported install channels are the **F-Droid self-hosted repo** and the **direct signed APK**, both Android. Google Play is internal-testing only, IzzyOnDroid is pending, and there is no iOS build.
> The three live, supported install channels are **Google Play**, the **F-Droid self-hosted repo**, and the **direct signed APK**, all Android. IzzyOnDroid is pending, and there is no iOS build.
---
## Quick Start
**Don't have a server yet?** Install the app and tap **Try a Demo** on the Sessions screen first — no setup required. It plays back a scripted bug-fix session through the app's real chat, diff, and permission-approval UI, offline, in about 30 seconds.
**Step 1 — Start opencode on your machine**
```bash
@@ -83,7 +92,7 @@ npm install -g opencode
OPENCODE_SERVER_PASSWORD=yourpassword opencode serve --hostname 0.0.0.0 --port 4096
```
**Step 2 — Install OpenCode Mobile** via the [F-Droid self-hosted repo or direct APK](#install-android) (or build from source — see [CONTRIBUTING.md](CONTRIBUTING.md)).
**Step 2 — Install OpenCode Mobile** via [Google Play, F-Droid, or a direct APK](#install-android) (or build from source — see [CONTRIBUTING.md](CONTRIBUTING.md)).
**Step 3 — Add a connection in the app**
@@ -132,6 +141,8 @@ OpenCode Mobile is a thin client. It speaks the opencode HTTP + SSE API: listing
| Feature | Status |
|---|---|
| Offline demo mode | Stable |
| First-run onboarding clarity | Stable |
| Multi-connection management | Stable |
| Session list + creation | Stable |
| Streaming chat | Stable |

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, useColorScheme, Linking } from "react-native"
import { Stack, useRouter } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
@@ -6,6 +6,13 @@ import { useTranslation } from "react-i18next"
import { MessageBubble, PermissionPrompt } from "../src/components/chat"
import { buildDemoScript, buildDemoCompletionMessage, buildDemoDenialMessage } from "../src/lib/demo-script"
import { SETUP_GUIDE_URL } from "../src/lib/links"
import { track, AnalyticsEvent } from "../src/lib/analytics"
import {
demoStepAdvancedProps,
demoCompletedOutcome,
demoExitedToConnectProps,
type DemoPermissionReply,
} from "../src/lib/demo-analytics"
// Fully offline, scripted walkthrough for installers with no self-hosted
// opencode server. ISOLATION: every message/part below is hardcoded local
@@ -22,12 +29,26 @@ export default function DemoScreen() {
// Built once per mount from pure, hardcoded data (src/lib/demo-script.ts).
const script = useMemo(() => buildDemoScript(), [])
const [reply, setReply] = useState<"once" | "always" | "reject" | null>(null)
const [reply, setReply] = useState<DemoPermissionReply | null>(null)
const handleReply = useCallback((r: "once" | "always" | "reject") => {
setReply(r)
// Fires once per screen mount — this is a fully offline, consent-gated
// event (track() is a no-op without telemetry consent, same as every
// other analytics call site).
useEffect(() => {
track(AnalyticsEvent.DemoStarted)
}, [])
const handleReply = useCallback((r: DemoPermissionReply) => {
setReply(r)
track(AnalyticsEvent.DemoStepAdvanced, demoStepAdvancedProps(r))
track(AnalyticsEvent.DemoCompleted, { outcome: demoCompletedOutcome(r) })
}, [])
const handleConnectPress = useCallback(() => {
track(AnalyticsEvent.DemoExitedToConnect, demoExitedToConnectProps(reply !== null))
router.push("/connection/add")
}, [reply, router])
const completion = useMemo(() => {
if (!reply) return null
return reply === "reject" ? buildDemoDenialMessage() : buildDemoCompletionMessage()
@@ -59,7 +80,7 @@ export default function DemoScreen() {
<Text style={[s.ctaSubtitle, isDark && s.metaDark]}>{t("demo.ctaSubtitle")}</Text>
<TouchableOpacity
style={s.connectButton}
onPress={() => router.push("/connection/add")}
onPress={handleConnectPress}
testID="demo-connect-button"
>
<Text style={s.connectButtonText}>{t("demo.connectButton")}</Text>

View File

@@ -1,8 +1,14 @@
# Google Play Store Listing — OpenCode Mobile
Copy-paste reference for completing the Play Console store listing for `cc.agentlabs.opencode`.
Copy-paste reference for the Play Console store listing for `cc.agentlabs.opencode`.
Paste these values directly into Play Console. Fields are validated against Play limits.
> **Status: live.** The app is publicly available on the production track —
> https://play.google.com/store/apps/details?id=cc.agentlabs.opencode (1K+ installs;
> see `distribution/retention-analysis.md`). The "First release strategy" and "Pending
> before first publish" sections below are kept as a historical record of how it got
> there — do not read them as the current state.
---
## Main store listing
@@ -322,25 +328,28 @@ Bump this file before tagging a release. Keep it under 500 chars. Per-language v
---
## First release strategy
## First release strategy (historical — completed)
1. **Internal testing** track first (up to 100 testers, no review) — what CI is wired for.
1. **Internal testing** track first (up to 100 testers, no review) — what CI was wired for.
2. **Closed testing** — 14+ days, 12+ testers required for new org accounts before promoting to production (Google's 2023 policy).
3. **Open testing** — optional intermediate step.
4. **Production** — only after Closed testing requirements met.
CI currently publishes to `internal` track. ✅
Result: the app cleared this path and is now live on the production track (see the
status note at the top of this file). CI's default dispatch track (`internal`) is
still used for routine test builds; production releases are dispatched explicitly
(`track=production`) — see `.github/workflows/publish-play-store.yml`.
---
## Pending before first publish
## Pending before first publish (historical — resolved)
- [ ] Identity verification (government ID upload, Google review)
- [ ] App icon (real PNG, not placeholder)
- [ ] Adaptive icon (real PNG)
- [ ] Feature graphic 1024×500
- [ ] At least 2 phone screenshots
- [ ] Privacy policy live at https://dzianisv.github.io/opencode-mobile/privacy/
- [ ] Decide pricing model
- [ ] Run IARC content rating questionnaire (after app created in Play Console)
- [ ] Complete Data safety form (after app created in Play Console)
- [x] Identity verification (government ID upload, Google review)
- [x] App icon (real PNG, not placeholder)
- [x] Adaptive icon (real PNG)
- [x] Feature graphic 1024×500
- [x] At least 2 phone screenshots
- [x] Privacy policy live at https://dzianisv.github.io/opencode-mobile/privacy/
- [x] Decide pricing model (free, no ads, no IAP)
- [x] Run IARC content rating questionnaire (after app created in Play Console)
- [x] Complete Data safety form (after app created in Play Console)

View File

@@ -88,7 +88,7 @@
<header>
<h1>OpenCode Mobile — Privacy Policy</h1>
<p class="meta">
Effective date: 2026-05-24 &nbsp;|&nbsp;
Effective date: 2026-07-18 &nbsp;|&nbsp;
Operator: VIBE TECHNOLOGIES, LLC &nbsp;|&nbsp;
App: OpenCode Mobile (<code>cc.agentlabs.opencode</code>)
</p>
@@ -232,6 +232,27 @@
<td>An agent response finishes</td>
<td>—</td>
</tr>
<tr>
<td><code>demo_started</code></td>
<td>You open the offline "Try a Demo" screen (no server, no network)</td>
<td>—</td>
</tr>
<tr>
<td><code>demo_step_advanced</code></td>
<td>You reply to the demo's scripted permission prompt</td>
<td><code>step_index</code>, <code>step_name</code>, <code>reply</code> ("once", "always", or
"reject")</td>
</tr>
<tr>
<td><code>demo_completed</code></td>
<td>The scripted demo reaches its end</td>
<td><code>outcome</code> ("completed" or "denied")</td>
</tr>
<tr>
<td><code>demo_exited_to_connect</code></td>
<td>You tap "Connect your own server" on the demo's CTA</td>
<td><code>reached_completion</code> (true/false)</td>
</tr>
</tbody>
</table>
@@ -239,7 +260,8 @@
What analytics events <strong>never</strong> contain: your server URL, hostname, IP address,
or port; prompts, messages, or AI responses; code or file contents; tokens or credentials;
raw error messages. Connection failures are reduced to a fixed list of coarse categories
before being sent.
before being sent. The demo screen is fully offline and hardcoded — these events describe
interaction with the scripted walkthrough, never real session content.
</p>
<p>
Analytics data is sent to PostHog's <strong>EU region</strong> (<code>eu.i.posthog.com</code>)
@@ -462,7 +484,7 @@
<footer>
&copy; 2026 VIBE TECHNOLOGIES, LLC. OpenCode Mobile is MIT-licensed open-source software.
Privacy policy effective 2026-05-24.
Privacy policy effective 2026-07-18.
</footer>
</body>

View File

@@ -1,6 +1,6 @@
# OpenCode Mobile — Privacy Policy
**Effective date:** 2026-05-24
**Effective date:** 2026-07-18
**Operator:** VIBE TECHNOLOGIES, LLC
**App:** OpenCode Mobile (`cc.agentlabs.opencode`)
@@ -63,8 +63,12 @@ Events collected, with their only properties:
| `connection_failed` | The connection test fails | `source`, `error_class` (a coarse category such as "timeout" or "unauthorized" — never the raw error text) |
| `message_sent` | You send a message to an agent session | — |
| `response_received` | An agent response finishes | — |
| `demo_started` | You open the offline "Try a Demo" screen (no server, no network) | — |
| `demo_step_advanced` | You reply to the demo's scripted permission prompt | `step_index`, `step_name`, `reply` ("once", "always", or "reject") |
| `demo_completed` | The scripted demo reaches its end | `outcome` ("completed" or "denied") |
| `demo_exited_to_connect` | You tap "Connect your own server" on the demo's CTA | `reached_completion` (true/false) |
What analytics events **never** contain: your server URL, hostname, IP address, or port; prompts, messages, or AI responses; code or file contents; tokens or credentials; raw error messages. Connection failures are reduced to a fixed list of coarse categories before being sent.
What analytics events **never** contain: your server URL, hostname, IP address, or port; prompts, messages, or AI responses; code or file contents; tokens or credentials; raw error messages. Connection failures are reduced to a fixed list of coarse categories before being sent. The demo screen is fully offline and hardcoded — these events describe interaction with the scripted walkthrough, never real session content.
Analytics data is sent to PostHog's **EU region** (`eu.i.posthog.com`) and is identified only by a random, app-generated anonymous ID — not linked to your name, email, or any account.

View File

@@ -26,7 +26,7 @@ and reach first value (message sent → response received)?** Nothing else is tr
| Destination | PostHog **EU region** — `https://eu.i.posthog.com` (override: `EXPO_PUBLIC_POSTHOG_HOST`) |
| API key | `EXPO_PUBLIC_POSTHOG_KEY` (CI secret; unset ⇒ analytics is a strict no-op) |
| Identity | PostHog's random app-generated anonymous ID only; no `identify()` calls, no user IDs |
| Code | `src/lib/analytics.ts` (wrapper), `src/lib/analytics-classify.ts` (error bucketing), `src/lib/telemetry.ts` (consent gate) |
| Code | `src/lib/analytics.ts` (wrapper), `src/lib/analytics-classify.ts` (error bucketing), `src/lib/demo-analytics.ts` (demo-funnel property derivation), `src/lib/telemetry.ts` (consent gate) |
## Event schema
@@ -42,6 +42,10 @@ section 3a of `distribution/privacy-policy.md`.
| `connection_failed` | Health check fails | `source`, `error_class` | `src/stores/connections.ts` |
| `message_sent` | User sends a prompt to an agent session (excludes slash commands) | — | `src/stores/sessions.ts` |
| `response_received` | Agent response finishes streaming (busy → idle), excluding user-aborted runs | — | `src/stores/events.ts` |
| `demo_started` | The offline `/demo` screen mounts (no server, no network) | — | `app/demo.tsx` |
| `demo_step_advanced` | User advances a step in the scripted demo (currently: replies to the demo's permission prompt) | `step_index`, `step_name`, `reply` (`"once"` \| `"always"` \| `"reject"`) | `app/demo.tsx`, `src/lib/demo-analytics.ts` |
| `demo_completed` | The scripted demo reaches its end (completion or denial message shown) — the key demo activation metric | `outcome` (`"completed"` \| `"denied"`) | `app/demo.tsx`, `src/lib/demo-analytics.ts` |
| `demo_exited_to_connect` | User taps "Connect your own server" on the demo's CTA card | `reached_completion` (boolean) | `app/demo.tsx` |
`error_class` is one of a fixed enum — `malformed-url`, `no-internet`, `server-unreachable`,
`unauthorized`, `tls-error`, `timeout`, `unknown` (`src/lib/analytics-classify.ts`). The raw

View File

@@ -95,7 +95,7 @@
<header>
<h1>OpenCode Mobile — Privacy Policy</h1>
<p class="meta">
Effective date: 2026-05-24 &nbsp;|&nbsp;
Effective date: 2026-07-18 &nbsp;|&nbsp;
Operator: VIBE TECHNOLOGIES, LLC &nbsp;|&nbsp;
App: OpenCode Mobile (<code>cc.agentlabs.opencode</code>)
</p>
@@ -239,6 +239,27 @@
<td>An agent response finishes</td>
<td>—</td>
</tr>
<tr>
<td><code>demo_started</code></td>
<td>You open the offline "Try a Demo" screen (no server, no network)</td>
<td>—</td>
</tr>
<tr>
<td><code>demo_step_advanced</code></td>
<td>You reply to the demo's scripted permission prompt</td>
<td><code>step_index</code>, <code>step_name</code>, <code>reply</code> ("once", "always", or
"reject")</td>
</tr>
<tr>
<td><code>demo_completed</code></td>
<td>The scripted demo reaches its end</td>
<td><code>outcome</code> ("completed" or "denied")</td>
</tr>
<tr>
<td><code>demo_exited_to_connect</code></td>
<td>You tap "Connect your own server" on the demo's CTA</td>
<td><code>reached_completion</code> (true/false)</td>
</tr>
</tbody>
</table>
@@ -246,7 +267,8 @@
What analytics events <strong>never</strong> contain: your server URL, hostname, IP address,
or port; prompts, messages, or AI responses; code or file contents; tokens or credentials;
raw error messages. Connection failures are reduced to a fixed list of coarse categories
before being sent.
before being sent. The demo screen is fully offline and hardcoded — these events describe
interaction with the scripted walkthrough, never real session content.
</p>
<p>
Analytics data is sent to PostHog's <strong>EU region</strong> (<code>eu.i.posthog.com</code>)
@@ -469,7 +491,7 @@
<footer>
&copy; 2026 VIBE TECHNOLOGIES, LLC. OpenCode Mobile is MIT-licensed open-source software.
Privacy policy effective 2026-05-24.<br>
Privacy policy effective 2026-07-18.<br>
<a href="https://dzianisv.github.io/opencode-mobile/">Home</a> &middot;
<a href="https://dzianisv.github.io/opencode-mobile/guide/">Setup guide</a> &middot;
<a href="https://github.com/dzianisv/opencode-mobile">GitHub</a>

View File

@@ -85,6 +85,17 @@ export enum AnalyticsEvent {
/** An agent response finished streaming (session transitioned busy -> idle),
* excluding user-aborted runs. */
ResponseReceived = "response_received",
/** Fired once when the offline `/demo` screen mounts. */
DemoStarted = "demo_started",
/** User advanced a step in the scripted demo (currently: replied to the
* demo's permission prompt). Always paired with `step_index`/`step_name`. */
DemoStepAdvanced = "demo_step_advanced",
/** The scripted demo reached its end (completion or denial message shown
* after the permission reply). The key activation metric for the demo —
* see distribution/retention-analysis.md. Always paired with `outcome`. */
DemoCompleted = "demo_completed",
/** User tapped "Connect your own server" on the demo's CTA card. */
DemoExitedToConnect = "demo_exited_to_connect",
}
/** Where a connection test/failure was initiated from. The activation funnel

View File

@@ -0,0 +1,41 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import {
DEMO_STEP_PERMISSION_REPLIED,
demoStepAdvancedProps,
demoCompletedOutcome,
demoExitedToConnectProps,
} from "./demo-analytics.ts"
test("demoStepAdvancedProps: includes the step index/name constant and the reply", () => {
const props = demoStepAdvancedProps("once")
assert.equal(props.step_index, DEMO_STEP_PERMISSION_REPLIED.index)
assert.equal(props.step_name, DEMO_STEP_PERMISSION_REPLIED.name)
assert.equal(props.reply, "once")
})
test("demoStepAdvancedProps: reply is passed through verbatim for each variant", () => {
assert.equal(demoStepAdvancedProps("always").reply, "always")
assert.equal(demoStepAdvancedProps("reject").reply, "reject")
})
test("demoStepAdvancedProps: properties are flat primitives only (no nested objects)", () => {
const props = demoStepAdvancedProps("once")
for (const value of Object.values(props)) {
assert.ok(["string", "number", "boolean"].includes(typeof value) || value === null)
}
})
test("demoCompletedOutcome: reject maps to denied", () => {
assert.equal(demoCompletedOutcome("reject"), "denied")
})
test("demoCompletedOutcome: once and always map to completed", () => {
assert.equal(demoCompletedOutcome("once"), "completed")
assert.equal(demoCompletedOutcome("always"), "completed")
})
test("demoExitedToConnectProps: reflects whether the demo reached completion first", () => {
assert.deepEqual(demoExitedToConnectProps(true), { reached_completion: true })
assert.deepEqual(demoExitedToConnectProps(false), { reached_completion: false })
})

38
src/lib/demo-analytics.ts Normal file
View File

@@ -0,0 +1,38 @@
// Pure derivation logic for the offline demo funnel's analytics properties
// (app/demo.tsx). Kept free of RN/PostHog imports — same pattern as
// analytics-classify.ts — so it is unit-testable with plain `node --test`
// and safe to import from the fully-offline demo screen.
import type { AnalyticsProps } from "./analytics"
/** Mirrors PermissionPrompt's `onReply` reply values (see
* src/components/chat/PermissionPrompt.tsx). */
export type DemoPermissionReply = "once" | "always" | "reject"
/** The demo funnel currently has exactly one step that "advances" past the
* initial script view: replying to the scripted permission prompt. Kept as
* a named constant (rather than inlined) so a future step can be added
* without hunting for magic numbers at each call site. */
export const DEMO_STEP_PERMISSION_REPLIED = { index: 1, name: "permission_replied" } as const
/** Properties for `demo_step_advanced` when the user replies to the
* scripted permission prompt. `reply` is a flat enum, not free text. */
export function demoStepAdvancedProps(reply: DemoPermissionReply): AnalyticsProps {
return {
step_index: DEMO_STEP_PERMISSION_REPLIED.index,
step_name: DEMO_STEP_PERMISSION_REPLIED.name,
reply,
}
}
/** Outcome bucket for `demo_completed` — never the raw completion/denial
* text, just whether the scripted permission was allowed or denied. */
export function demoCompletedOutcome(reply: DemoPermissionReply): "completed" | "denied" {
return reply === "reject" ? "denied" : "completed"
}
/** Properties for `demo_exited_to_connect`. The Connect CTA is reachable
* before the user has replied to the permission prompt, so this records
* whether they'd reached the end of the scripted flow first. */
export function demoExitedToConnectProps(reachedCompletion: boolean): AnalyticsProps {
return { reached_completion: reachedCompletion }
}