diff --git a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx index 68145d121e4..a52a7f54a87 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx @@ -49,6 +49,7 @@ import { ProjectSectionHeader } from "./components/ProjectSectionHeader"; import { ScopeBar } from "./components/ScopeBar"; import { WorkspaceRow } from "./components/WorkspaceRow"; import { useAgentLiveActivity } from "./hooks/useAgentLiveActivity"; +import { useAppReviewPrompt } from "./hooks/useAppReviewPrompt"; import { useCloudRepoPrefixes } from "./hooks/useCloudRepoPrefixes"; import { useFirstPaint } from "./hooks/useFirstPaint"; import { @@ -144,6 +145,7 @@ export function HomeScreen() { const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const queryClient = useQueryClient(); + useAppReviewPrompt(); const setTargetKey = useNewSessionPreferencesStore( (state) => state.setTargetKey, ); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useCreateCloudWorkspace/useCreateCloudWorkspace.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useCreateCloudWorkspace/useCreateCloudWorkspace.ts index a81f307f6b0..b2ada3e3f43 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useCreateCloudWorkspace/useCreateCloudWorkspace.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useCreateCloudWorkspace/useCreateCloudWorkspace.ts @@ -10,6 +10,7 @@ import { useSession } from "@/lib/auth/client"; import { errorCopy, transportFailureKind } from "@/lib/errors"; import { posthog } from "@/lib/posthog"; import { apiClient } from "@/lib/trpc/client"; +import { useAppReviewStore } from "@/screens/(authenticated)/stores/appReviewStore"; interface CreateCloudWorkspaceArgs { /** Null means the repo's default branch, resolved by the branch query. */ @@ -87,6 +88,7 @@ export function useCreateCloudWorkspace() { model, effort, }); + useAppReviewStore.getState().recordWorkspaceCreated(); // Seed the list before navigating: the workspace screen decides // between "provisioning" and "not found" off this cache, and even // one refetch round trip is long enough to flash the wrong one. diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAppReviewPrompt/index.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAppReviewPrompt/index.ts new file mode 100644 index 00000000000..9fce5ce058e --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAppReviewPrompt/index.ts @@ -0,0 +1 @@ +export { useAppReviewPrompt } from "./useAppReviewPrompt"; diff --git a/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAppReviewPrompt/useAppReviewPrompt.ts similarity index 54% rename from apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts rename to apps/mobile/screens/(authenticated)/(home)/home/hooks/useAppReviewPrompt/useAppReviewPrompt.ts index 6365fb73819..889aaf191b3 100644 --- a/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/useAppReviewPrompt.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useAppReviewPrompt/useAppReviewPrompt.ts @@ -1,38 +1,40 @@ import * as Application from "expo-application"; +import { useFocusEffect } from "expo-router"; 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 MESSAGES_BEFORE_PROMPT = 5; +const WORKSPACES_BEFORE_PROMPT = 2; 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. + * Asks for an App Store rating when the user comes back to Home after doing + * something on their phone, once they have sent enough messages or created + * enough workspaces to have formed an opinion. 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. */ export function useAppReviewPrompt() { const posthog = usePostHog(); - return useCallback( - (moment: AppReviewMoment) => { + useFocusEffect( + useCallback(() => { 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) { + if (!store.actedSinceHome) return; + store.clearActedSinceHome(); + if ( + store.messagesSent < MESSAGES_BEFORE_PROMPT && + store.workspacesCreated < WORKSPACES_BEFORE_PROMPT + ) { return; } const version = Application.nativeApplicationVersion ?? "0.0.0"; if (store.lastPromptedVersion === version) return; + const now = Date.now(); if ( store.lastPromptedAt !== null && now - store.lastPromptedAt < PROMPT_COOLDOWN_MS @@ -44,13 +46,12 @@ export function useAppReviewPrompt() { void StoreReview.isAvailableAsync().then((available) => { if (!available) return; posthog.capture("app_review_prompt_requested", { - moment, - positive_moments: moments, + messages_sent: store.messagesSent, + workspaces_created: store.workspacesCreated, }); return StoreReview.requestReview(); }); }, SETTLE_MS); - }, - [posthog], + }, [posthog]), ); } diff --git a/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/index.ts b/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/index.ts deleted file mode 100644 index b7fcf1a0a83..00000000000 --- a/apps/mobile/screens/(authenticated)/hooks/useAppReviewPrompt/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type AppReviewMoment, useAppReviewPrompt } from "./useAppReviewPrompt"; diff --git a/apps/mobile/screens/(authenticated)/hooks/useCreateTerminalWorkspace/useCreateTerminalWorkspace.ts b/apps/mobile/screens/(authenticated)/hooks/useCreateTerminalWorkspace/useCreateTerminalWorkspace.ts index 8ce1b5d04b6..9b07c2a8156 100644 --- a/apps/mobile/screens/(authenticated)/hooks/useCreateTerminalWorkspace/useCreateTerminalWorkspace.ts +++ b/apps/mobile/screens/(authenticated)/hooks/useCreateTerminalWorkspace/useCreateTerminalWorkspace.ts @@ -8,6 +8,7 @@ import { getHostServiceClientByUrl } from "@/lib/host-service/client"; import { isMissingProcedureError } from "@/lib/host-service/errors"; import { posthog } from "@/lib/posthog"; import { getHostTerminalsQueryKey } from "@/screens/(authenticated)/(home)/home/hooks/useHostTerminals"; +import { useAppReviewStore } from "@/screens/(authenticated)/stores/appReviewStore"; import { type PendingWorkspaceCreateInput, usePendingWorkspaceCreatesStore, @@ -146,6 +147,7 @@ export function useCreateTerminalWorkspace() { model, effort, }); + useAppReviewStore.getState().recordWorkspaceCreated(); return { workspaceId }; } catch (error) { // A transport failure proves nothing about the worktree: the diff --git a/apps/mobile/screens/(authenticated)/stores/appReviewStore/appReviewStore.ts b/apps/mobile/screens/(authenticated)/stores/appReviewStore/appReviewStore.ts index 7c44e9881ab..7025b7a074e 100644 --- a/apps/mobile/screens/(authenticated)/stores/appReviewStore/appReviewStore.ts +++ b/apps/mobile/screens/(authenticated)/stores/appReviewStore/appReviewStore.ts @@ -2,45 +2,79 @@ 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. + * What the user has done on their phone (messages sent to a session, + * workspaces created) plus when we last asked for an App Store rating. + * `actedSinceHome` is memory-only: it is what makes the next return to Home + * the moment to ask, and a cold launch never counts as a return. */ interface AppReviewStore { - positiveMoments: number; - lastMomentAt: number | null; + messagesSent: number; + workspacesCreated: number; + actedSinceHome: boolean; lastPromptedAt: number | null; lastPromptedVersion: string | null; - recordPositiveMoment: (now: number) => number; + recordMessageSent: () => void; + recordWorkspaceCreated: () => void; + clearActedSinceHome: () => void; markPrompted: (now: number, version: string) => void; } +type PersistedAppReview = Pick< + AppReviewStore, + | "messagesSent" + | "workspacesCreated" + | "lastPromptedAt" + | "lastPromptedVersion" +>; + export const useAppReviewStore = create()( persist( - (set, get) => ({ - positiveMoments: 0, - lastMomentAt: null, + (set) => ({ + messagesSent: 0, + workspacesCreated: 0, + actedSinceHome: false, 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; - }, + recordMessageSent: () => + set((state) => ({ + messagesSent: state.messagesSent + 1, + actedSinceHome: true, + })), + recordWorkspaceCreated: () => + set((state) => ({ + workspacesCreated: state.workspacesCreated + 1, + actedSinceHome: true, + })), + clearActedSinceHome: () => set({ actedSinceHome: false }), markPrompted: (now, version) => set({ lastPromptedAt: now, lastPromptedVersion: version }), }), { name: "app-review-v1", + version: 1, storage: createJSONStorage(() => AsyncStorage), + partialize: ({ + messagesSent, + workspacesCreated, + lastPromptedAt, + lastPromptedVersion, + }): PersistedAppReview => ({ + messagesSent, + workspacesCreated, + lastPromptedAt, + lastPromptedVersion, + }), + migrate: (persisted) => { + const { lastPromptedAt = null, lastPromptedVersion = null } = + (persisted ?? {}) as Partial; + return { + messagesSent: 0, + workspacesCreated: 0, + lastPromptedAt, + lastPromptedVersion, + }; + }, }, ), ); diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx index 3e5f58cca0f..1fff9de041a 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx @@ -43,7 +43,6 @@ import { import { HeaderNotice } from "@/screens/(authenticated)/components/HeaderNotice"; import { PressableScale } from "@/screens/(authenticated)/components/PressableScale"; import { useAgentIconUris } from "@/screens/(authenticated)/hooks/useAgentIconUris"; -import { useAppReviewPrompt } from "@/screens/(authenticated)/hooks/useAppReviewPrompt"; import { useCreateTerminalWorkspace } from "@/screens/(authenticated)/hooks/useCreateTerminalWorkspace"; import { useSlashCommands } from "@/screens/(authenticated)/hooks/useSlashCommands"; import { workspaceDraftKey } from "@/screens/(authenticated)/stores/composerDraftsStore"; @@ -416,7 +415,6 @@ export function WorkspaceScreen() { const markTerminalSeen = useTerminalSeenStore( (state) => state.markTerminalSeen, ); - const requestAppReview = useAppReviewPrompt(); const activeRow = rows.find((row) => row.terminalId === activeTerminalId); const slashCommands = useSlashCommands({ machineId: host?.machineId ?? null, @@ -428,8 +426,7 @@ export function WorkspaceScreen() { if (activeRow?.attention !== "review") return; if (activeRow.lastEventAt === null) return; markTerminalSeen(activeRow.terminalId, activeRow.lastEventAt); - requestAppReview("session_completed"); - }, [activeRow, markTerminalSeen, requestAppReview]); + }, [activeRow, markTerminalSeen]); // Brand marks as file URIs: the composer draws them, and neither SwiftUI nor // the bridge can read a Metro asset reference. diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalComposer/TerminalComposer.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalComposer/TerminalComposer.tsx index 5e08f868637..8aa8520d7af 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalComposer/TerminalComposer.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalComposer/TerminalComposer.tsx @@ -17,6 +17,7 @@ import { useAttachmentsSheet } from "@/screens/(authenticated)/hooks/useAttachme import { useAttachmentUploads } from "@/screens/(authenticated)/hooks/useAttachmentUploads"; import { useComposerDraft } from "@/screens/(authenticated)/hooks/useComposerDraft"; import { usePasteAttachments } from "@/screens/(authenticated)/hooks/usePasteAttachments"; +import { useAppReviewStore } from "@/screens/(authenticated)/stores/appReviewStore"; import { workspaceDraftKey } from "@/screens/(authenticated)/stores/composerDraftsStore"; import { QUICK_KEYS, type TerminalQuickKey } from "./constants"; import { @@ -202,6 +203,7 @@ export const TerminalComposer = forwardRef< has_attachments: allowAttachments && files.length > 0, attachment_count: allowAttachments ? files.length : 0, }); + useAppReviewStore.getState().recordMessageSent(); // Clear what actually went out, and only that. The text always did. // The tray only did if this session could carry it — a plain shell // submits without attachments, and the draft belongs to the workspace 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 7e7544889de..f47ebf7786a 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 @@ -17,7 +17,6 @@ import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; import { posthog } from "@/lib/posthog"; import { HeaderNotice } from "@/screens/(authenticated)/components/HeaderNotice"; -import { useAppReviewPrompt } from "@/screens/(authenticated)/hooks/useAppReviewPrompt"; import { PullRequestCard } from "./components/PullRequestCard"; import { PullRequestDescription } from "./components/PullRequestDescription"; import { PullRequestHeader } from "./components/PullRequestHeader"; @@ -55,7 +54,6 @@ export function PullRequestScreen() { }); }, [workspaceId, pullNumber]); - const requestAppReview = useAppReviewPrompt(); const merge = useMergePullRequest({ workspaceId, owner, @@ -63,7 +61,6 @@ export function PullRequestScreen() { pullNumber, onMerged: () => { void refetch(); - requestAppReview("pr_merged"); }, }); const actions = usePullRequestActions({