From b7899b9f8a7406e74f439f79c0b918439be1590d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 17 Aug 2026 19:02:34 -0700 Subject: [PATCH 1/2] feat(mobile): "Rate Superset" settings row and App Store rating prompt after positive moments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Support gains a "Rate Superset" row directly under Contact Support that opens the App Store write-review page. Separately, Apple's native rating sheet is requested right after Superset did something for the user: immediately when a pull request is merged from the phone, and on the third session an agent finished for them. Never asked twice for the same build or within 90 days; Apple's 3-per-year cap and the system opt-out apply on top. Fires app_review_prompt_requested in PostHog. expo-store-review is pinned to 56.0.3, the same release batch as the installed expo-modules-core; verified booting in a fresh dev client. --- apps/mobile/package.json | 1 + .../hooks/useAppReviewPrompt/index.ts | 1 + .../useAppReviewPrompt/useAppReviewPrompt.ts | 54 +++++++++++++++++++ .../settings/SettingsScreen.tsx | 19 +++++-- .../stores/appReviewStore/appReviewStore.ts | 46 ++++++++++++++++ .../stores/appReviewStore/index.ts | 1 + .../[id]/WorkspaceScreen/WorkspaceScreen.tsx | 5 +- .../[pullRequestId]/PullRequestScreen.tsx | 7 ++- bun.lock | 3 ++ packages/shared/src/constants.ts | 1 + 10 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/index.ts create mode 100644 apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts create mode 100644 apps/mobile/screens/(authenticated)/stores/appReviewStore/appReviewStore.ts create mode 100644 apps/mobile/screens/(authenticated)/stores/appReviewStore/index.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 48335c7dc30..46da6673580 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -95,6 +95,7 @@ "expo-secure-store": "56.0.4", "expo-speech-recognition": "56.0.1", "expo-status-bar": "56.0.4", + "expo-store-review": "56.0.3", "expo-system-ui": "56.0.5", "expo-web-browser": "56.0.6", "katex": "0.17.0", diff --git a/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/index.ts b/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/index.ts new file mode 100644 index 00000000000..b7fcf1a0a83 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/index.ts @@ -0,0 +1 @@ +export { type AppReviewMoment, useAppReviewPrompt } from "./useAppReviewPrompt"; diff --git a/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts b/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts new file mode 100644 index 00000000000..41c03d8b90a --- /dev/null +++ b/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts @@ -0,0 +1,54 @@ +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 build or within 90 days. + */ +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; + } + setTimeout(() => { + void StoreReview.isAvailableAsync().then((available) => { + if (!available) return; + useAppReviewStore.getState().markPrompted(now, version); + posthog.capture("app_review_prompt_requested", { + moment, + positive_moments: moments, + }); + return StoreReview.requestReview(); + }); + }, SETTLE_MS); + }, + [posthog], + ); +} diff --git a/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx index d293be7826e..ef779492832 100644 --- a/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx +++ b/apps/mobile/screens/(authenticated)/settings/SettingsScreen.tsx @@ -21,6 +21,7 @@ import { SettingsSection } from "./components/SettingsSection"; import { UserAvatar } from "./components/UserAvatar"; const BILLING_URL = `${env.EXPO_PUBLIC_WEB_URL ?? COMPANY.MARKETING_URL}/settings/billing`; +const WRITE_REVIEW_URL = `${COMPANY.APP_STORE_URL}?action=write-review`; function openUrl(url: string) { Linking.openURL(url).catch(() => { @@ -226,6 +227,18 @@ export function SettingsScreen() { trailing={} onPress={() => openUrl(COMPANY.DOCS_URL)} /> + + } + label="Community" + trailing={} + onPress={() => openUrl(COMPANY.DISCORD_URL)} + /> } - label="Community" + label="Rate Superset" trailing={} - onPress={() => openUrl(COMPANY.DISCORD_URL)} + onPress={() => openUrl(WRITE_REVIEW_URL)} isLast /> diff --git a/apps/mobile/screens/(authenticated)/stores/appReviewStore/appReviewStore.ts b/apps/mobile/screens/(authenticated)/stores/appReviewStore/appReviewStore.ts new file mode 100644 index 00000000000..7c44e9881ab --- /dev/null +++ b/apps/mobile/screens/(authenticated)/stores/appReviewStore/appReviewStore.ts @@ -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; +} + +export const useAppReviewStore = create()( + 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), + }, + ), +); diff --git a/apps/mobile/screens/(authenticated)/stores/appReviewStore/index.ts b/apps/mobile/screens/(authenticated)/stores/appReviewStore/index.ts new file mode 100644 index 00000000000..fe81ee7b830 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/stores/appReviewStore/index.ts @@ -0,0 +1 @@ +export { useAppReviewStore } from "./appReviewStore"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx index d77a85d0065..e953bc802d7 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx @@ -25,6 +25,7 @@ import { } from "@/screens/(authenticated)/(home)/home/hooks/useHostTerminals"; import type { GlassComposerHandle } from "@/screens/(authenticated)/components/GlassComposer"; import { PressableScale } from "@/screens/(authenticated)/components/PressableScale"; +import { useAppReviewPrompt } from "@/screens/(authenticated)/hooks/useAppReviewPrompt"; import { useTerminalSeenStore } from "@/screens/(authenticated)/stores/terminalSeenStore"; import { useTerminalTabOrderStore } from "@/screens/(authenticated)/stores/terminalTabOrderStore"; import { PullRequestsButton } from "../components/PullRequestsButton"; @@ -127,12 +128,14 @@ export function WorkspaceScreen() { const markTerminalSeen = useTerminalSeenStore( (state) => state.markTerminalSeen, ); + const requestAppReview = useAppReviewPrompt(); const activeRow = rows.find((row) => row.terminalId === activeTerminalId); useEffect(() => { if (activeRow?.attention !== "review") return; if (activeRow.lastEventAt === null) return; markTerminalSeen(activeRow.terminalId, activeRow.lastEventAt); - }, [activeRow, markTerminalSeen]); + requestAppReview("session_completed"); + }, [activeRow, markTerminalSeen, requestAppReview]); const invalidateTerminals = useCallback(() => { if (!host) return; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/pull-request/[pullRequestId]/PullRequestScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/pull-request/[pullRequestId]/PullRequestScreen.tsx index 5ea4a1ad7c3..4c3d6e87d68 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/pull-request/[pullRequestId]/PullRequestScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/pull-request/[pullRequestId]/PullRequestScreen.tsx @@ -15,6 +15,7 @@ import { } from "react-native"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; +import { useAppReviewPrompt } from "@/screens/(authenticated)/hooks/useAppReviewPrompt"; import { HeaderNotice } from "./components/HeaderNotice"; import { PullRequestCard } from "./components/PullRequestCard"; import { PullRequestDescription } from "./components/PullRequestDescription"; @@ -38,12 +39,16 @@ export function PullRequestScreen() { repo, } = usePullRequestRoute(); + const requestAppReview = useAppReviewPrompt(); const merge = useMergePullRequest({ workspaceId, owner, repo, pullNumber, - onMerged: refetch, + onMerged: () => { + void refetch(); + requestAppReview("pr_merged"); + }, }); const [notice, setNotice] = useState(null); diff --git a/bun.lock b/bun.lock index 46bdf7ebdc3..42d2c6ce40a 100644 --- a/bun.lock +++ b/bun.lock @@ -561,6 +561,7 @@ "expo-secure-store": "56.0.4", "expo-speech-recognition": "56.0.1", "expo-status-bar": "56.0.4", + "expo-store-review": "56.0.3", "expo-system-ui": "56.0.5", "expo-web-browser": "56.0.6", "katex": "0.17.0", @@ -4561,6 +4562,8 @@ "expo-status-bar": ["expo-status-bar@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA=="], + "expo-store-review": ["expo-store-review@56.0.3", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-pLlSmizlcXC3dYdydKpYtNslEEr1pyUMHH4YV/zc/HmnEIAIG752N0NadAQ+jxX5i5B8iDwOfYc9iJe9Qyc0UA=="], + "expo-symbols": ["expo-symbols@56.0.7", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-OddQ8qWSHaTN8UvyVkcwZSGw0NEH/XRXsofA8pjX4aThyKH60Q3U0tcoberb0cCBCTbfd8SuyjYMbtwnQeYh3g=="], "expo-system-ui": ["expo-system-ui@56.0.5", "", { "dependencies": { "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-n1MmnUArV4cc3gVed9fGtluPme00PE9axKVx+NHbKxHFMam5l4GcOI7PxbYKFNx8o7WA1LRD7eLW33agmZrxGg=="], diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9293920152d..7dee2df3fb9 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -37,6 +37,7 @@ export const COMPANY = { FOUNDERS_MAIL_TO: `mailto:founders@${ROOT_DOMAIN}`, REPORT_ISSUE_URL: "https://github.com/superset-sh/superset/issues/new", DISCORD_URL: "https://discord.gg/cZeD9WYcV7", + APP_STORE_URL: "https://apps.apple.com/app/id6788926383", STATUS_URL: `https://status.${ROOT_DOMAIN}`, TRUST_URL: `https://trust.${ROOT_DOMAIN}`, JOIN_US_URL: `${MARKETING_URL}/join-us`, From 274eb368f32abecd17082347d27cdefc8752e903 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 17 Aug 2026 19:11:02 -0700 Subject: [PATCH 2/2] fix(mobile): reserve the review prompt before the settle delay so back-to-back moments can't double-request --- .../hooks/useAppReviewPrompt/useAppReviewPrompt.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts b/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts index 41c03d8b90a..6365fb73819 100644 --- a/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts +++ b/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts @@ -16,7 +16,9 @@ const SETTLE_MS = 1500; * 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 build or within 90 days. + * 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(); @@ -37,10 +39,10 @@ export function useAppReviewPrompt() { ) { return; } + store.markPrompted(now, version); setTimeout(() => { void StoreReview.isAvailableAsync().then((available) => { if (!available) return; - useAppReviewStore.getState().markPrompted(now, version); posthog.capture("app_review_prompt_requested", { moment, positive_moments: moments,