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..6365fb73819
--- /dev/null
+++ b/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts
@@ -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);
+ },
+ [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`,