feat(feedback): prompt for a store review after successful sessions

Add expo-store-review (SDK 54-matched via `expo install`) and wire a
one-time in-app rating prompt into the SSE busy->idle "session completed"
transition in stores/events.ts — the same signal that already drives the
"Task completed" notification, so it only fires on genuine success, never
on session.error.

State (success count, one-time "asked" flag) persists in expo-secure-store,
mirroring the consent pattern in telemetry.ts. The threshold check is split
into store-review-policy.ts, free of expo imports, so it's unit-testable
with plain `node --test` (same split as buildAuth in auth.ts).

F-Droid/Play-Services-absent safety comes from the library itself:
StoreReview.isAvailableAsync() resolves false there, so requestReview() is
never called and there's no store-URL fallback configured in app.json.
This commit is contained in:
engineer
2026-07-16 15:46:28 -07:00
parent e72e82df8e
commit d3ee3d9e82
6 changed files with 122 additions and 3 deletions

15
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@opencode-ai/mobile",
"version": "0.4.3",
"version": "0.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@opencode-ai/mobile",
"version": "0.4.3",
"version": "0.4.7",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8",
@@ -26,6 +26,7 @@
"expo-secure-store": "~15.0.8",
"expo-speech-recognition": "3.0.1",
"expo-status-bar": "~3.0.9",
"expo-store-review": "~9.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
@@ -5254,6 +5255,16 @@
"react-native": "*"
}
},
"node_modules/expo-store-review": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/expo-store-review/-/expo-store-review-9.0.9.tgz",
"integrity": "sha512-99vS7edXlKzPcdjrzVlMQWc4zOyq4khQfFjhNqJgpGP+AgRn4U0LaZkHIrVjmzolryD3rcHJSiUQH9Vi0sD0MQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "54.0.24",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",

View File

@@ -30,6 +30,7 @@
"expo-secure-store": "~15.0.8",
"expo-speech-recognition": "3.0.1",
"expo-status-bar": "~3.0.9",
"expo-store-review": "~9.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
@@ -55,4 +56,4 @@
]
}
}
}
}

View File

@@ -0,0 +1,18 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { shouldRequestReview, DEFAULT_REVIEW_THRESHOLD } from "./store-review-policy.ts"
test("below the default threshold -> false", () => {
assert.equal(shouldRequestReview(0), false)
assert.equal(shouldRequestReview(DEFAULT_REVIEW_THRESHOLD - 1), false)
})
test("at or above the default threshold -> true", () => {
assert.equal(shouldRequestReview(DEFAULT_REVIEW_THRESHOLD), true)
assert.equal(shouldRequestReview(DEFAULT_REVIEW_THRESHOLD + 5), true)
})
test("custom threshold is respected", () => {
assert.equal(shouldRequestReview(2, 5), false)
assert.equal(shouldRequestReview(5, 5), true)
})

View File

@@ -0,0 +1,18 @@
// Pure success-count threshold logic for the in-app store-review prompt.
//
// Kept free of expo imports (SecureStore, expo-store-review) so it's
// unit-testable with plain `node --test`, the same split used for
// buildAuth() in auth.ts: "Extracted ... so the ... rule is unit-testable
// without pulling in zustand/expo (which have no resolver outside Metro)."
export const DEFAULT_REVIEW_THRESHOLD = 3
/**
* Decide whether a running count of genuinely successful sessions (never
* incremented on error) has reached the point where we should attempt a
* store review prompt. The caller is responsible for tracking the
* "already asked" flag separately and never calling this again once true.
*/
export function shouldRequestReview(successCount: number, threshold: number = DEFAULT_REVIEW_THRESHOLD): boolean {
return successCount >= threshold
}

67
src/lib/store-review.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* In-app store review prompt.
*
* Nudges the user toward an App Store / Play Store rating after they've hit
* a few genuinely successful sessions — never from an error path. Persisted
* state lives in expo-secure-store, mirroring the consent pattern in
* telemetry.ts:
* - a running count of successful sessions
* - a one-time "already asked" flag so we prompt at most once, ever
*
* expo-store-review is safe to call unconditionally: requestReview() is only
* invoked after isAvailableAsync() returns true, and isAvailableAsync()
* resolves to false wherever the native review API is absent (F-Droid builds
* with no Play Services, older platform versions, etc.) — see
* https://docs.expo.dev/versions/latest/sdk/storereview/. In that case this
* module simply never prompts; it does not fall back to opening a store URL.
*
* Usage: call recordSuccessfulSession() from a success-only code path (e.g.
* the SSE busy -> idle "session completed" transition in stores/events.ts).
*/
import * as SecureStore from "expo-secure-store"
import * as StoreReview from "expo-store-review"
import { shouldRequestReview, DEFAULT_REVIEW_THRESHOLD } from "./store-review-policy"
const COUNT_KEY = "opencode_review_success_count"
const ASKED_KEY = "opencode_review_asked"
// Serialize calls so rapid-fire session completions can't race the
// read-increment-write of the persisted counter.
let transition = Promise.resolve()
/**
* Record a genuinely positive moment (a session finished successfully).
* Never call this from an error path. At most one review request is ever
* issued per install.
*/
export function recordSuccessfulSession(): Promise<void> {
const next = transition.then(recordSuccessfulSessionInternal)
transition = next.catch(() => undefined)
return next
}
async function recordSuccessfulSessionInternal(): Promise<void> {
try {
const [asked, storedCount] = await Promise.all([
SecureStore.getItemAsync(ASKED_KEY),
SecureStore.getItemAsync(COUNT_KEY),
])
if (asked === "true") return
// Clamp the persisted counter at the threshold — we only need to know
// "have we reached it", not track an ever-growing lifetime total.
const count = Math.min(Number(storedCount ?? "0") + 1, DEFAULT_REVIEW_THRESHOLD)
await SecureStore.setItemAsync(COUNT_KEY, String(count))
if (!shouldRequestReview(count)) return
const available = await StoreReview.isAvailableAsync()
if (!available) return
await StoreReview.requestReview()
await SecureStore.setItemAsync(ASKED_KEY, "true")
} catch {
// A review-prompt failure must never affect session handling.
}
}

View File

@@ -5,6 +5,7 @@ import { send as notify } from "../lib/notifications"
import { sanitizeBody } from "../lib/notify-format"
import { statusFromPart } from "../lib/status-labels"
import { addBreadcrumb } from "../lib/sentry"
import { recordSuccessfulSession } from "../lib/store-review"
import type { Client, Part, Session, Message } from "../lib/sdk"
// Session status from the server
@@ -186,6 +187,9 @@ export const useEvents = create<EventsState>((set, get) => ({
body: sanitizeBody(match?.title, "Session finished processing"),
sessionId: sessionID,
})
// Genuinely positive moment (never fired on error) — count it
// toward the one-time store review prompt.
void recordSuccessfulSession()
}
break
}