Repository navigation
feat(mobile): "Rate Superset" settings row and App Store rating prompt after positive moments #6588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { type AppReviewMoment, useAppReviewPrompt } from "./useAppReviewPrompt"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import * as Application from "expo-application"; | ||
| import * as StoreReview from "expo-store-review"; | ||
| import { usePostHog } from "posthog-react-native"; | ||
| import { useCallback } from "react"; | ||
| import { useAppReviewStore } from "@/screens/(authenticated)/stores/appReviewStore"; | ||
|
|
||
| export type AppReviewMoment = "pr_merged" | "session_completed"; | ||
|
|
||
| const SESSIONS_BEFORE_PROMPT = 3; | ||
| const PROMPT_COOLDOWN_MS = 90 * 24 * 60 * 60 * 1000; | ||
| const SETTLE_MS = 1500; | ||
|
|
||
| /** | ||
| * Asks for an App Store rating only right after Superset did something for | ||
| * the user: they merged a pull request from their phone (qualifies at once) | ||
| * or opened a session an agent finished for them (qualifies on the third | ||
| * distinct one). The only UI is Apple's own sheet, which may decline to show, | ||
| * caps itself at three a year, and honours the system-wide opt-out; on top of | ||
| * that we never ask twice about the same version or within 90 days. The | ||
| * prompted mark is written before the settle delay so two qualifying moments | ||
| * in quick succession can't both schedule a request. | ||
| */ | ||
| export function useAppReviewPrompt() { | ||
| const posthog = usePostHog(); | ||
| return useCallback( | ||
| (moment: AppReviewMoment) => { | ||
| if (!useAppReviewStore.persist.hasHydrated()) return; | ||
| const now = Date.now(); | ||
| const store = useAppReviewStore.getState(); | ||
| const moments = store.recordPositiveMoment(now); | ||
| if (moment === "session_completed" && moments < SESSIONS_BEFORE_PROMPT) { | ||
| return; | ||
| } | ||
| const version = Application.nativeApplicationVersion ?? "0.0.0"; | ||
| if (store.lastPromptedVersion === version) return; | ||
| if ( | ||
| store.lastPromptedAt !== null && | ||
| now - store.lastPromptedAt < PROMPT_COOLDOWN_MS | ||
| ) { | ||
| return; | ||
| } | ||
| store.markPrompted(now, version); | ||
| setTimeout(() => { | ||
| void StoreReview.isAvailableAsync().then((available) => { | ||
| if (!available) return; | ||
| posthog.capture("app_review_prompt_requested", { | ||
| moment, | ||
| positive_moments: moments, | ||
| }); | ||
| return StoreReview.requestReview(); | ||
| }); | ||
| }, SETTLE_MS); | ||
|
Comment on lines
+43
to
+52
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
file=$(git ls-files | rg '^apps/mobile/screens/\(authenticated\)/hooks/useAppReviewPrompt/useAppReviewPrompt\.ts$')
printf '%s\n' "$file"
wc -l "$file"
ast-grep outline "$file" --lang typescript
printf '\n--- implementation ---\n'
cat -n "$file"
printf '\n--- hook usages and related store symbols ---\n'
rg -n --glob '*.{ts,tsx}' 'useAppReviewPrompt|markPrompted|lastPromptedAt|SETTLE_MS|app_review_prompt_requested|StoreReview\.(isAvailableAsync|requestReview)' apps/mobileRepository: superset-sh/superset Length of output: 5591 🏁 Script executed: #!/bin/bash
set -e
store=$(git ls-files | rg '^apps/mobile/screens/\(authenticated\)/stores/appReviewStore/appReviewStore\.ts$')
workspace=$(git ls-files | rg 'apps/mobile/screens/\(authenticated\)/workspace/\[id\]/WorkspaceScreen/WorkspaceScreen\.tsx$')
pull_request=$(git ls-files | rg 'apps/mobile/screens/\(authenticated\)/workspace/\[id\]/pull-request/\[pullRequestId\]/PullRequestScreen\.tsx$')
printf '%s\n' "$store" "$workspace" "$pull_request"
printf '\n--- store ---\n'
cat -n "$store"
printf '\n--- WorkspaceScreen review-prompt context ---\n'
rg -n -C 12 --glob '*.tsx' 'requestAppReview|on.*(Merge|Session)|merge|session' "$workspace"
printf '\n--- PullRequestScreen review-prompt context ---\n'
rg -n -C 12 --glob '*.tsx' 'requestAppReview|on.*(Merge|Session)|merge|session' "$pull_request"
printf '\n--- all direct invocations ---\n'
rg -n -C 5 --glob '*.{ts,tsx}' 'requestAppReview\(' apps/mobileRepository: superset-sh/superset Length of output: 15825 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from dataclasses import dataclass
`@dataclass`
class Store:
last_prompted_at: int | None = None
last_prompted_version: str | None = None
store = Store()
scheduled = []
native_requests = []
def request_app_review(now: int, version: str, moment: str, moments: int) -> None:
# This is the eligibility and scheduling logic from useAppReviewPrompt.
if store.last_prompted_version == version:
return
if store.last_prompted_at is not None and now - store.last_prompted_at < 90 * 24 * 60 * 60 * 1000:
return
scheduled.append((now, version, moment, moments))
def run_delayed_callback(callback) -> None:
# The current callback checks availability, then marks and requests.
available = True
if not available:
return
now, version, moment, moments = callback
store.last_prompted_at = now
store.last_prompted_version = version
native_requests.append((moment, moments))
request_app_review(1_000, "1.2.3", "pr_merged", 1)
request_app_review(1_001, "1.2.3", "session_completed", 2)
for callback in scheduled:
run_delayed_callback(callback)
assert len(scheduled) == 2
assert len(native_requests) == 2
print("scheduled callbacks:", len(scheduled))
print("native requests:", len(native_requests))
print("final persisted mark:", store.last_prompted_at, store.last_prompted_version)
PYRepository: superset-sh/superset Length of output: 334 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
class Store:
def __init__(self):
self.last_prompted_at = None
self.last_prompted_version = None
store = Store()
scheduled = []
native_requests = []
def request_app_review(now, version, moment, moments):
if store.last_prompted_version == version:
return
if (
store.last_prompted_at is not None
and now - store.last_prompted_at < 90 * 24 * 60 * 60 * 1000
):
return
scheduled.append((now, version, moment, moments))
def run_delayed_callback(callback):
available = True
if not available:
return
now, version, moment, moments = callback
store.last_prompted_at = now
store.last_prompted_version = version
native_requests.append((moment, moments))
request_app_review(1_000, "1.2.3", "pr_merged", 1)
request_app_review(1_001, "1.2.3", "session_completed", 2)
for callback in scheduled:
run_delayed_callback(callback)
assert len(scheduled) == 2
assert len(native_requests) == 2
print("scheduled callbacks:", len(scheduled))
print("native requests:", len(native_requests))
print("final persisted mark:", store.last_prompted_at, store.last_prompted_version)
PYRepository: superset-sh/superset Length of output: 233 Reserve the review request before scheduling the delay. Two qualifying calls can schedule callbacks before either callback updates the store. Both callbacks can then capture 🤖 Prompt for AI Agents |
||
| }, | ||
| [posthog], | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import AsyncStorage from "@react-native-async-storage/async-storage"; | ||
| import { create } from "zustand"; | ||
| import { createJSONStorage, persist } from "zustand/middleware"; | ||
|
|
||
| const MOMENT_DEDUPE_MS = 60 * 60 * 1000; | ||
|
|
||
| /** | ||
| * Tally of moments Superset did something for the user (a merged pull | ||
| * request, a session an agent finished for them) plus when we last asked | ||
| * for an App Store rating. Moments closer than an hour apart count once, so | ||
| * an agent stopping several times while the user watches is one moment. | ||
| */ | ||
| interface AppReviewStore { | ||
| positiveMoments: number; | ||
| lastMomentAt: number | null; | ||
| lastPromptedAt: number | null; | ||
| lastPromptedVersion: string | null; | ||
| recordPositiveMoment: (now: number) => number; | ||
| markPrompted: (now: number, version: string) => void; | ||
|
Comment on lines
+14
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Track completed sessions separately from other positive moments.
The timestamp-only dedupe also merges distinct sessions completed within one hour. Persist a completed-session count that increments only for a unique session identifier. Keep the aggregate positive-moment count only for analytics if needed. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| export const useAppReviewStore = create<AppReviewStore>()( | ||
| persist( | ||
| (set, get) => ({ | ||
| positiveMoments: 0, | ||
| lastMomentAt: null, | ||
| lastPromptedAt: null, | ||
| lastPromptedVersion: null, | ||
| recordPositiveMoment: (now) => { | ||
| const { positiveMoments, lastMomentAt } = get(); | ||
| if (lastMomentAt !== null && now - lastMomentAt < MOMENT_DEDUPE_MS) { | ||
| return positiveMoments; | ||
| } | ||
| const next = positiveMoments + 1; | ||
| set({ positiveMoments: next, lastMomentAt: now }); | ||
| return next; | ||
| }, | ||
| markPrompted: (now, version) => | ||
| set({ lastPromptedAt: now, lastPromptedVersion: version }), | ||
| }), | ||
| { | ||
| name: "app-review-v1", | ||
| storage: createJSONStorage(() => AsyncStorage), | ||
| }, | ||
| ), | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { useAppReviewStore } from "./appReviewStore"; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
For Expo SDK 56 expo-application on iOS, what values do nativeApplicationVersion and nativeApplicationBuildNumber return, and which value identifies an app build?💡 Result:
In Expo SDK 56, the expo-application library provides access to native application information at runtime [1][2]. The two relevant properties on iOS return the following values: 1. Application.nativeApplicationVersion: Returns the human-readable version string of the application, which corresponds to the CFBundleShortVersionString value in the iOS Info.plist [1][3][2]. This is typically the value displayed in the app store (e.g., 1.0.0). 2. Application.nativeBuildVersion: Returns the internal build version of the application, which corresponds to the CFBundleVersion value in the iOS Info.plist [1][3][2]. This value is set using the ios.buildNumber property in your app configuration [1][2]. Regarding which value identifies an app build, Application.nativeBuildVersion is the property intended to distinguish between different binaries or builds for app stores [1][2]. It is the developer-facing build version used by app stores to identify specific build iterations [4][5]. Note that there is no property named nativeApplicationBuildNumber in the expo-application library; the correct property to access the build version is Application.nativeBuildVersion [1][3][2].
Citations:
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 4154
Use
Application.nativeBuildVersionfor the build gate.Application.nativeApplicationVersionis the marketing version. Compare and persistApplication.nativeBuildVersion, which identifies the iOS build. (nativeApplicationBuildNumberis not anexpo-applicationAPI.)🤖 Prompt for AI Agents