Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
39 changes: 1 addition & 38 deletions scripts/lifecyclePolicy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
162 changes: 162 additions & 0 deletions scripts/reviewPromptService.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 5 additions & 3 deletions src/hooks/useLibrarySupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
Expand Down
37 changes: 2 additions & 35 deletions src/services/lifecyclePolicy.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,7 +8,6 @@ export interface LifecycleState {
savedNoteIds: string[];
communityPromptState: CommunityPromptState;
communityHandledAt: string | null;
reviewPromptedVersions: string[];
}

export function createLifecycleState(now: string): LifecycleState {
Expand All @@ -21,7 +17,6 @@ export function createLifecycleState(now: string): LifecycleState {
savedNoteIds: [],
communityPromptState: 'pending',
communityHandledAt: null,
reviewPromptedVersions: [],
};
}

Expand All @@ -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),
};
}

Expand All @@ -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,
Expand All @@ -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));
}
Expand Down
Loading
Loading