diff --git a/package.json b/package.json index 58859e7..4c89739 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "start": "expo start", "ios": "expo run:ios", "android": "expo run:android", - "test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs", + "test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs", "test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs", - "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs", + "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs", "test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/scripts/lifecyclePolicy.test.mjs b/scripts/lifecyclePolicy.test.mjs index 2ef5187..3b66cc0 100644 --- a/scripts/lifecyclePolicy.test.mjs +++ b/scripts/lifecyclePolicy.test.mjs @@ -2,14 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { COMMUNITY_NOTE_THRESHOLD, - REVIEW_AFTER_COMMUNITY_DELAY_MS, - REVIEW_MIN_AGE_MS, - REVIEW_NOTE_THRESHOLD, createLifecycleState, normalizeLifecycleState, recordUniqueNoteSave, shouldOfferCommunity, - shouldRequestReview, } from '../src/services/lifecyclePolicy.ts'; const startedAt = '2026-01-01T00:00:00.000Z'; @@ -50,52 +46,19 @@ test('community prompt never returns after it is resolved', () => { } }); -test('review waits for five unique notes, seven days, and community handling', () => { - const now = Date.parse(startedAt) + REVIEW_MIN_AGE_MS; - const enoughNotes = stateWithSaves(REVIEW_NOTE_THRESHOLD); - const handled = { - ...enoughNotes, - communityPromptState: 'dismissed', - communityHandledAt: new Date( - now - REVIEW_AFTER_COMMUNITY_DELAY_MS, - ).toISOString(), - }; - - assert.equal( - shouldRequestReview(handled, '1.0', now - 1), - false, - ); - assert.equal(shouldRequestReview(enoughNotes, '1.0', now), false); - assert.equal(shouldRequestReview(handled, '1.0', now), true); -}); - -test('review is limited to once per app version', () => { - const state = { - ...stateWithSaves(REVIEW_NOTE_THRESHOLD), - communityPromptState: 'joined', - communityHandledAt: new Date(Date.parse(startedAt)).toISOString(), - reviewPromptedVersions: ['1.0'], - }; - const now = Date.parse(startedAt) + REVIEW_MIN_AGE_MS; - assert.equal(shouldRequestReview(state, '1.0', now), false); - assert.equal(shouldRequestReview(state, '1.1', now), true); -}); - test('normalization repairs corrupt fields and bounds saved note ids', () => { const normalized = normalizeLifecycleState( { firstSeenAt: 'not-a-date', savedNoteIds: ['a', 'a', 'b', 'c', 'd', 'e', 'f'], communityPromptState: 'unexpected', - reviewPromptedVersions: ['1.0', '1.0', '1.1'], }, startedAt, ); assert.equal(normalized.firstSeenAt, startedAt); - assert.equal(normalized.savedNoteIds.length, REVIEW_NOTE_THRESHOLD); + assert.equal(normalized.savedNoteIds.length, COMMUNITY_NOTE_THRESHOLD); assert.equal(normalized.communityPromptState, 'pending'); - assert.deepEqual(normalized.reviewPromptedVersions, ['1.0', '1.1']); }); test('normalization recovers the legacy shown state after an interrupted prompt', () => { diff --git a/scripts/reviewPromptService.test.mjs b/scripts/reviewPromptService.test.mjs new file mode 100644 index 0000000..1fc47bd --- /dev/null +++ b/scripts/reviewPromptService.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; +import { createPromiseQueue } from '../src/utils/promiseQueue.ts'; + +const key = '@opennotes:reviewPrompt:v1'; +const minute = 60 * 1000; +const day = 24 * 60 * minute; +const source = ts.transpileModule( + readFileSync(new URL('../src/services/reviewPromptService.ts', import.meta.url), 'utf8'), + { compilerOptions: { module: ts.ModuleKind.CommonJS } }, +).outputText; + +function harness(initial) { + let now = Date.parse('2026-01-01T00:00:00Z'); + let raw = initial ? JSON.stringify(initial) : null; + const calls = []; + const store = { + available: true, + action: true, + fail: false, + async isAvailableAsync() { return this.available; }, + async hasAction() { return this.action; }, + async requestReview() { + if (this.fail) throw new Error('native request failed'); + calls.push(now); + }, + }; + const storage = { + async getItem(requestedKey) { + assert.equal(requestedKey, key); + return raw; + }, + async setItem(requestedKey, value) { + assert.equal(requestedKey, key); + raw = value; + }, + }; + class Clock extends Date { + constructor(...args) { super(...(args.length ? args : [now])); } + static now() { return now; } + } + const exports = {}; + vm.runInNewContext(source, { + exports, + __DEV__: false, + Date: Clock, + require(name) { + if (name === '@react-native-async-storage/async-storage') return { default: storage }; + if (name === 'expo-store-review') return store; + if (name === '../utils/promiseQueue') return { createPromiseQueue }; + throw new Error(`Unexpected import: ${name}`); + }, + }); + return { + ...exports, calls, store, + advance(ms) { now += ms; }, + state() { return JSON.parse(raw); }, + }; +} + +test('first successful save qualifies immediately without a timer or other actions', async () => { + const h = harness(); + await h.recordReviewSave(); + assert.equal(h.calls.length, 0, 'saving only records eligibility'); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 1); + assert.equal(h.state().pendingPositiveMoment, false); +}); + +test('no automatic prompt before a successful save, even after seven days', async () => { + for (const initial of [undefined, { + notesCreated: 20, + notesOpened: 20, + notesExported: 1, + pendingPositiveMoment: true, + }]) { + const h = harness(initial); + h.advance(7 * day); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 0); + } +}); + +test('concurrent saves and library checks preserve counts and request only once', async () => { + const h = harness(); + await Promise.all(Array.from({ length: 8 }, () => h.recordReviewSave())); + assert.equal(h.state().notesSaved, 8); + await Promise.all(Array.from({ length: 5 }, () => h.requestReviewAfterPositiveMoment())); + assert.equal(h.calls.length, 1); +}); + +test('cooldown requires 120 days and a new action, with a three-request cap', async () => { + const h = harness(); + await h.recordReviewSave(); + await h.requestReviewAfterPositiveMoment(); + h.advance(120 * day); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 1); + await h.recordReviewSave(); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 2); + await h.recordReviewSave(); + h.advance(120 * day - 1); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 2); + h.advance(1); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 3); + await h.recordReviewSave(); + h.advance(120 * day); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 3); +}); + +test('unavailable or failed native requests remain eligible for retry', async () => { + const h = harness(); + await h.recordReviewSave(); + h.store.available = false; + await h.requestReviewAfterPositiveMoment(); + h.store.available = true; + h.store.action = false; + await h.requestReviewAfterPositiveMoment(); + h.store.action = true; + h.store.fail = true; + await assert.rejects(h.requestReviewAfterPositiveMoment(), /native request failed/); + assert.equal(h.state().promptCount, 0); + assert.equal(h.state().pendingPositiveMoment, true); + h.store.fail = false; + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 1); +}); + +test('restored service honors review history from the original release', async () => { + const h = harness({ + firstSeenAt: '2025-01-01T00:00:00Z', + lastPromptedAt: '2025-12-31T00:00:00Z', + promptCount: 1, + notesCreated: 2, + notesSaved: 3, + pendingPositiveMoment: true, + }); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 0); + h.advance(119 * day); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 1); + assert.equal(h.state().promptCount, 2); +}); + +test('manual rating works immediately and starts the automatic cooldown', async () => { + const h = harness(); + assert.equal(await h.requestManualReview(), true); + await h.recordReviewSave(); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 1); + h.advance(120 * day); + await h.requestReviewAfterPositiveMoment(); + assert.equal(h.calls.length, 2); +}); diff --git a/src/hooks/useLibrarySupport.ts b/src/hooks/useLibrarySupport.ts index 6577458..07a0fa6 100644 --- a/src/hooks/useLibrarySupport.ts +++ b/src/hooks/useLibrarySupport.ts @@ -4,9 +4,11 @@ import { useFocusEffect } from 'expo-router'; import { OPEN_NOTES_LINKS, openExternalLink } from '../services/externalLinks'; import { t } from '../i18n'; import { - claimCommunityPrompt, - requestAutomaticReviewIfEligible, requestManualReview, + requestReviewAfterPositiveMoment, +} from '../services/reviewPromptService'; +import { + claimCommunityPrompt, resolveCommunityPrompt, } from '../services/lifecycleService'; @@ -38,7 +40,7 @@ export function useLibrarySupport({ if (active) onShowCommunity(); return; } - await requestAutomaticReviewIfEligible(); + if (active) await requestReviewAfterPositiveMoment(); } catch (error) { if (__DEV__) console.warn('[useLibrarySupport] prompt failed', error); } diff --git a/src/services/lifecyclePolicy.ts b/src/services/lifecyclePolicy.ts index 368ca4c..0903adb 100644 --- a/src/services/lifecyclePolicy.ts +++ b/src/services/lifecyclePolicy.ts @@ -1,7 +1,4 @@ export const COMMUNITY_NOTE_THRESHOLD = 3; -export const REVIEW_NOTE_THRESHOLD = 5; -export const REVIEW_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000; -export const REVIEW_AFTER_COMMUNITY_DELAY_MS = 24 * 60 * 60 * 1000; export type CommunityPromptState = 'pending' | 'joined' | 'dismissed'; @@ -11,7 +8,6 @@ export interface LifecycleState { savedNoteIds: string[]; communityPromptState: CommunityPromptState; communityHandledAt: string | null; - reviewPromptedVersions: string[]; } export function createLifecycleState(now: string): LifecycleState { @@ -21,7 +17,6 @@ export function createLifecycleState(now: string): LifecycleState { savedNoteIds: [], communityPromptState: 'pending', communityHandledAt: null, - reviewPromptedVersions: [], }; } @@ -43,12 +38,11 @@ export function normalizeLifecycleState( lastSuccessfulSaveAt: isIsoDate(value.lastSuccessfulSaveAt) ? value.lastSuccessfulSaveAt : null, - savedNoteIds: uniqueStrings(value.savedNoteIds).slice(0, REVIEW_NOTE_THRESHOLD), + savedNoteIds: uniqueStrings(value.savedNoteIds).slice(0, COMMUNITY_NOTE_THRESHOLD), communityPromptState, communityHandledAt: isIsoDate(value.communityHandledAt) ? value.communityHandledAt : null, - reviewPromptedVersions: uniqueStrings(value.reviewPromptedVersions), }; } @@ -59,7 +53,7 @@ export function recordUniqueNoteSave( ): LifecycleState { const savedNoteIds = state.savedNoteIds.includes(noteId) ? state.savedNoteIds - : [...state.savedNoteIds, noteId].slice(0, REVIEW_NOTE_THRESHOLD); + : [...state.savedNoteIds, noteId].slice(0, COMMUNITY_NOTE_THRESHOLD); return { ...state, @@ -75,33 +69,6 @@ export function shouldOfferCommunity(state: LifecycleState): boolean { ); } -export function shouldRequestReview( - state: LifecycleState, - appVersion: string, - nowMs: number, -): boolean { - if (state.savedNoteIds.length < REVIEW_NOTE_THRESHOLD) return false; - if ( - state.communityPromptState !== 'joined' && - state.communityPromptState !== 'dismissed' - ) { - return false; - } - if (state.reviewPromptedVersions.includes(appVersion)) return false; - - const firstSeenMs = Date.parse(state.firstSeenAt); - const communityHandledMs = state.communityHandledAt - ? Date.parse(state.communityHandledAt) - : Number.NaN; - if (!Number.isFinite(firstSeenMs) || !Number.isFinite(communityHandledMs)) { - return false; - } - return ( - nowMs - firstSeenMs >= REVIEW_MIN_AGE_MS && - nowMs - communityHandledMs >= REVIEW_AFTER_COMMUNITY_DELAY_MS - ); -} - function isIsoDate(value: unknown): value is string { return typeof value === 'string' && Number.isFinite(Date.parse(value)); } diff --git a/src/services/lifecycleService.ts b/src/services/lifecycleService.ts index d21dd40..96aebf9 100644 --- a/src/services/lifecycleService.ts +++ b/src/services/lifecycleService.ts @@ -1,22 +1,18 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import Constants from 'expo-constants'; -import * as StoreReview from 'expo-store-review'; import { createLifecycleState, normalizeLifecycleState, recordUniqueNoteSave, shouldOfferCommunity, - shouldRequestReview, type CommunityPromptState, type LifecycleState, } from './lifecyclePolicy'; import { createPromiseQueue } from '../utils/promiseQueue'; +import { recordReviewSave } from './reviewPromptService'; const LIFECYCLE_KEY = '@opennotes:lifecycle:v1'; -const APP_VERSION = Constants.expoConfig?.version ?? 'unknown'; const stateQueue = createPromiseQueue(); -let reviewRequest: Promise | null = null; let communityPromptClaimedThisSession = false; function withStateLock(operation: () => Promise): Promise { @@ -45,6 +41,7 @@ async function writeStateUnlocked(state: LifecycleState): Promise { export async function recordSuccessfulNoteSave(noteId: string): Promise { if (!noteId) return; + await recordReviewSave(); await withStateLock(async () => { const state = await readStateUnlocked(); const next = recordUniqueNoteSave(state, noteId, new Date().toISOString()); @@ -74,43 +71,3 @@ export async function resolveCommunityPrompt( }); }); } - -export function requestAutomaticReviewIfEligible(): Promise { - if (reviewRequest) return reviewRequest; - reviewRequest = requestAutomaticReview().finally(() => { - reviewRequest = null; - }); - return reviewRequest; -} - -async function requestAutomaticReview(): Promise { - const state = await withStateLock(readStateUnlocked); - if (!shouldRequestReview(state, APP_VERSION, Date.now())) return false; - - const available = await StoreReview.isAvailableAsync(); - if (!available || !(await StoreReview.hasAction())) return false; - - await StoreReview.requestReview(); - await markReviewRequested(); - return true; -} - -export async function requestManualReview(): Promise { - const available = await StoreReview.isAvailableAsync(); - if (!available || !(await StoreReview.hasAction())) return false; - - await StoreReview.requestReview(); - await markReviewRequested(); - return true; -} - -async function markReviewRequested(): Promise { - await withStateLock(async () => { - const state = await readStateUnlocked(); - if (state.reviewPromptedVersions.includes(APP_VERSION)) return; - await writeStateUnlocked({ - ...state, - reviewPromptedVersions: [...state.reviewPromptedVersions, APP_VERSION], - }); - }); -} diff --git a/src/services/reviewPromptService.ts b/src/services/reviewPromptService.ts new file mode 100644 index 0000000..a0c6a73 --- /dev/null +++ b/src/services/reviewPromptService.ts @@ -0,0 +1,89 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as StoreReview from 'expo-store-review'; +import { createPromiseQueue } from '../utils/promiseQueue'; + +const stateQueue = createPromiseQueue(); + +interface ReviewPromptState { + lastPromptedAt: string | null; + promptCount: number; + notesSaved: number; + pendingPositiveMoment: boolean; +} + +const KEY = '@opennotes:reviewPrompt:v1'; +const PROMPT_COOLDOWN_MS = 120 * 24 * 60 * 60 * 1000; +const MAX_PROMPTS = 3; + +function initialState(): ReviewPromptState { + return { + lastPromptedAt: null, + promptCount: 0, + notesSaved: 0, + pendingPositiveMoment: false, + }; +} + +async function readState(): Promise { + const raw = await AsyncStorage.getItem(KEY); + if (!raw) return initialState(); + try { + return { ...initialState(), ...(JSON.parse(raw) as Partial) }; + } catch (error) { + if (__DEV__) console.warn('[reviewPromptService] invalid stored state', error); + return initialState(); + } +} + +async function writeState(state: ReviewPromptState): Promise { + await AsyncStorage.setItem(KEY, JSON.stringify(state)); +} + +async function requestAutomaticReview(): Promise { + const state = await readState(); + if (!state.pendingPositiveMoment || state.notesSaved < 1 || state.promptCount >= MAX_PROMPTS) return; + + const now = Date.now(); + const lastPrompted = state.lastPromptedAt ? Date.parse(state.lastPromptedAt) : 0; + if (lastPrompted && now - lastPrompted < PROMPT_COOLDOWN_MS) return; + + await requestReview(state); +} + +async function requestReview(state: ReviewPromptState): Promise { + const available = await StoreReview.isAvailableAsync(); + if (!available) return false; + + const hasAction = await StoreReview.hasAction(); + if (!hasAction) return false; + + await StoreReview.requestReview(); + await writeState({ + ...state, + pendingPositiveMoment: false, + promptCount: state.promptCount + 1, + lastPromptedAt: new Date().toISOString(), + }); + return true; +} + +export function recordReviewSave(): Promise { + return stateQueue.enqueue(async () => { + const state = await readState(); + await writeState({ + ...state, + notesSaved: state.notesSaved + 1, + pendingPositiveMoment: true, + }); + }).catch((error) => { + if (__DEV__) console.warn('[reviewPromptService] save tracking failed', error); + }); +} + +export function requestReviewAfterPositiveMoment(): Promise { + return stateQueue.enqueue(requestAutomaticReview); +} + +export function requestManualReview(): Promise { + return stateQueue.enqueue(async () => requestReview(await readState())); +}