Skip to content

Commit b530b3d

Browse files
committed
feat(onboarding): add lifecycle support prompts
1 parent 8d784a8 commit b530b3d

20 files changed

Lines changed: 1085 additions & 474 deletions

app/folder/[id].tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
listFolders,
2727
renameFolder,
2828
} from '../../src/services/foldersRepo';
29-
import { recordReviewSignal } from '../../src/services/reviewPromptService';
3029
import type { BackgroundType, FolderMetadata, NoteMetadata } from '../../src/types/note';
3130

3231
type Action =
@@ -82,14 +81,12 @@ export default function FolderScreen() {
8281
backgroundType,
8382
title: title.trim() || undefined,
8483
});
85-
void recordReviewSignal('note_created');
8684
router.push(`/note/${meta.id}`);
8785
return;
8886
}
8987

9088
const meta = await createPdfNoteFromPicker({ folderId: folder.id, title });
9189
if (meta) {
92-
void recordReviewSignal('note_created');
9390
router.push(`/note/${meta.id}`);
9491
}
9592
} catch (error) {
@@ -168,7 +165,6 @@ export default function FolderScreen() {
168165
key={note.id}
169166
note={note}
170167
onPress={() => {
171-
void recordReviewSignal('note_opened');
172168
router.push(`/note/${note.id}`);
173169
}}
174170
onLongPress={() => {

app/index.tsx

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import { RenameDialog } from '../src/components/library/RenameDialog';
1919
import { FolderPickerSheet } from '../src/components/library/FolderPickerSheet';
2020
import { CreateNoteBackgroundSheet } from '../src/components/library/CreateNoteBackgroundSheet';
2121
import { OnboardingExperience } from '../src/components/onboarding/OnboardingExperience';
22-
import { AboutSheet } from '../src/components/library/AboutSheet';
22+
import { CommunityInviteSheet } from '../src/components/library/CommunityInviteSheet';
23+
import { OpenNotesSheet } from '../src/components/library/OpenNotesSheet';
2324
import { LibrarySection } from '../src/components/library/LibrarySection';
2425
import { useOnboarding } from '../src/hooks/useOnboarding';
25-
import { OPEN_NOTES_LINKS, openExternalLink } from '../src/services/externalLinks';
26+
import { useLibrarySupport } from '../src/hooks/useLibrarySupport';
2627
import { useTheme } from '../src/hooks/useTheme';
2728
import { spacing } from '../src/theme/spacing';
2829
import {
@@ -39,15 +40,12 @@ import {
3940
listFolders,
4041
renameFolder,
4142
} from '../src/services/foldersRepo';
42-
import {
43-
recordReviewSignal,
44-
requestReviewAfterPositiveMoment,
45-
} from '../src/services/reviewPromptService';
4643
import type { BackgroundType, FolderMetadata, NoteMetadata } from '../src/types/note';
4744

4845
type Action =
4946
| { kind: 'newItem' }
50-
| { kind: 'about' }
47+
| { kind: 'openNotes' }
48+
| { kind: 'community' }
5149
| { kind: 'createNoteBackground' }
5250
| { kind: 'noteMenu'; note: NoteMetadata }
5351
| { kind: 'folderMenu'; folder: FolderMetadata }
@@ -83,10 +81,21 @@ export default function LibraryScreen() {
8381
useFocusEffect(
8482
useCallback(() => {
8583
void refresh();
86-
void requestReviewAfterPositiveMoment();
8784
}, [refresh]),
8885
);
8986

87+
const closeSupport = useCallback(() => setAction(null), []);
88+
const showCommunity = useCallback(
89+
() => setAction({ kind: 'community' }),
90+
[],
91+
);
92+
const { dismissCommunity, joinCommunity, rateOpenNotes } = useLibrarySupport({
93+
canShowAutomaticPrompt:
94+
onboarding.ready && !onboarding.visible && action === null,
95+
onClose: closeSupport,
96+
onShowCommunity: showCommunity,
97+
});
98+
9099
const rootNotes = useMemo(
91100
() =>
92101
notes
@@ -109,7 +118,6 @@ export default function LibraryScreen() {
109118
const openNote = useCallback(
110119
(id: string) => {
111120
void Haptics.selectionAsync();
112-
void recordReviewSignal('note_opened');
113121
router.push(`/note/${id}`);
114122
},
115123
[router],
@@ -134,14 +142,12 @@ export default function LibraryScreen() {
134142
backgroundType,
135143
title: title.trim() || undefined,
136144
});
137-
void recordReviewSignal('note_created');
138145
openNote(meta.id);
139146
return;
140147
}
141148

142149
const meta = await createPdfNoteFromPicker({ folderId: null, title });
143150
if (meta) {
144-
void recordReviewSignal('note_created');
145151
openNote(meta.id);
146152
}
147153
} catch (error) {
@@ -226,32 +232,16 @@ export default function LibraryScreen() {
226232
);
227233
}, [refresh]);
228234

229-
const openUrl = useCallback(async (url: string) => {
230-
await openExternalLink(url, 'LibraryScreen');
231-
}, []);
232-
233235
return (
234236
<SafeAreaView edges={['top']} style={[styles.flex, { backgroundColor: theme.colors.background }]}>
235237
<LibraryHeader
236238
title="OpenNotes"
237239
rightActions={[
238240
{
239-
key: 'github',
240-
icon: 'logo-github',
241-
accessibilityLabel: 'Open OpenNotes on GitHub',
242-
onPress: () => void openUrl(OPEN_NOTES_LINKS.github),
243-
},
244-
{
245-
key: 'x',
246-
icon: 'logo-x',
247-
accessibilityLabel: 'Open Mark Miller on X',
248-
onPress: () => void openUrl(OPEN_NOTES_LINKS.x),
249-
},
250-
{
251-
key: 'about',
252-
icon: 'information-circle-outline',
253-
accessibilityLabel: 'About OpenNotes',
254-
onPress: () => setAction({ kind: 'about' }),
241+
key: 'openNotes',
242+
icon: 'heart-outline',
243+
accessibilityLabel: 'Support OpenNotes',
244+
onPress: () => setAction({ kind: 'openNotes' }),
255245
},
256246
]}
257247
/>
@@ -313,15 +303,23 @@ export default function LibraryScreen() {
313303

314304
<NewItemFAB onPress={() => setAction({ kind: 'newItem' })} />
315305

316-
<AboutSheet
317-
visible={action?.kind === 'about'}
306+
<OpenNotesSheet
307+
visible={action?.kind === 'openNotes'}
318308
onClose={() => setAction(null)}
309+
onJoinCommunity={() => void joinCommunity()}
310+
onRate={() => void rateOpenNotes()}
319311
onViewIntroduction={() => {
320312
setAction(null);
321313
onboarding.show();
322314
}}
323315
/>
324316

317+
<CommunityInviteSheet
318+
visible={action?.kind === 'community'}
319+
onClose={() => void dismissCommunity()}
320+
onJoin={() => void joinCommunity()}
321+
/>
322+
325323
<OnboardingExperience
326324
visible={onboarding.ready && onboarding.visible}
327325
onComplete={onboarding.finish}

app/note/[id].tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import {
5757
type PickedImageResult,
5858
} from '../../src/services/imageInsertStorage';
5959
import { exportNotebookAsPdf } from '../../src/services/exportService';
60-
import { recordReviewSignal } from '../../src/services/reviewPromptService';
60+
import { recordSuccessfulNoteSave } from '../../src/services/lifecycleService';
6161
import { textBoxId, insertedElementId } from '../../src/utils/id';
6262
import type { NoteMetadata } from '../../src/types/note';
6363
import type { ToolDescriptor } from '../../src/utils/toolPalette';
@@ -206,8 +206,11 @@ export default function NoteScreen() {
206206
...canvasData,
207207
pages: mergedPages,
208208
};
209-
await saveNoteBody(id, merged);
210-
void recordReviewSignal('note_saved');
209+
const result = await saveNoteBody(id, merged);
210+
if (!result.ok) {
211+
throw new Error('Note body storage did not complete successfully.');
212+
}
213+
await recordSuccessfulNoteSave(id);
211214
}, [id, mergeStoredPreviews, rememberPagePreviews]);
212215

213216
const [autosaveEnabled, setAutosaveEnabled] = useState(true);
@@ -675,8 +678,6 @@ export default function NoteScreen() {
675678
'Export failed',
676679
result.error ?? 'Could not generate a PDF. Please try again.',
677680
);
678-
} else {
679-
void recordReviewSignal('note_exported');
680681
}
681682
} catch (error) {
682683
if (__DEV__) console.warn('[NoteScreen] export failed', error);

assets/onboarding/help-it-grow.png

-749 KB
Loading
-860 KB
Loading

assets/onboarding/write-freely.png

-739 KB
Loading

ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ PODS:
257257
- hermes-engine (0.81.5):
258258
- hermes-engine/Pre-built (= 0.81.5)
259259
- hermes-engine/Pre-built (0.81.5)
260-
- MathNotesMobileInk (0.3.1):
260+
- MathNotesMobileInk (0.3.2):
261261
- React-Core
262262
- react-native-skia
263263
- RCTDeprecation (0.81.5)
@@ -2546,7 +2546,7 @@ SPEC CHECKSUMS:
25462546
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
25472547
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
25482548
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
2549-
MathNotesMobileInk: 344466b9422b741c400619302265642d7b572bd1
2549+
MathNotesMobileInk: 397dd2c1d132a84b594547f911a625cd667f77e9
25502550
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
25512551
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
25522552
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"start": "expo start",
1818
"ios": "expo run:ios",
1919
"android": "expo run:android",
20+
"test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs",
2021
"typecheck": "tsc --noEmit -p tsconfig.json"
2122
},
2223
"dependencies": {

scripts/lifecyclePolicy.test.mjs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
import {
4+
COMMUNITY_NOTE_THRESHOLD,
5+
REVIEW_AFTER_COMMUNITY_DELAY_MS,
6+
REVIEW_MIN_AGE_MS,
7+
REVIEW_NOTE_THRESHOLD,
8+
createLifecycleState,
9+
normalizeLifecycleState,
10+
recordUniqueNoteSave,
11+
shouldOfferCommunity,
12+
shouldRequestReview,
13+
} from '../src/services/lifecyclePolicy.ts';
14+
15+
const startedAt = '2026-01-01T00:00:00.000Z';
16+
17+
function stateWithSaves(count) {
18+
let state = createLifecycleState(startedAt);
19+
for (let index = 0; index < count; index += 1) {
20+
state = recordUniqueNoteSave(
21+
state,
22+
`note-${index}`,
23+
new Date(Date.parse(startedAt) + index * 1000).toISOString(),
24+
);
25+
}
26+
return state;
27+
}
28+
29+
test('community prompt becomes eligible on the third unique saved note', () => {
30+
assert.equal(shouldOfferCommunity(stateWithSaves(COMMUNITY_NOTE_THRESHOLD - 1)), false);
31+
assert.equal(shouldOfferCommunity(stateWithSaves(COMMUNITY_NOTE_THRESHOLD)), true);
32+
});
33+
34+
test('repeated saves of one note do not advance milestones', () => {
35+
let state = createLifecycleState(startedAt);
36+
for (let index = 0; index < 20; index += 1) {
37+
state = recordUniqueNoteSave(state, 'same-note', startedAt);
38+
}
39+
assert.deepEqual(state.savedNoteIds, ['same-note']);
40+
assert.equal(shouldOfferCommunity(state), false);
41+
});
42+
43+
test('community prompt never returns after it is resolved', () => {
44+
const eligible = stateWithSaves(COMMUNITY_NOTE_THRESHOLD);
45+
for (const communityPromptState of ['joined', 'dismissed']) {
46+
assert.equal(
47+
shouldOfferCommunity({ ...eligible, communityPromptState }),
48+
false,
49+
);
50+
}
51+
});
52+
53+
test('review waits for five unique notes, seven days, and community handling', () => {
54+
const now = Date.parse(startedAt) + REVIEW_MIN_AGE_MS;
55+
const enoughNotes = stateWithSaves(REVIEW_NOTE_THRESHOLD);
56+
const handled = {
57+
...enoughNotes,
58+
communityPromptState: 'dismissed',
59+
communityHandledAt: new Date(
60+
now - REVIEW_AFTER_COMMUNITY_DELAY_MS,
61+
).toISOString(),
62+
};
63+
64+
assert.equal(
65+
shouldRequestReview(handled, '1.0', now - 1),
66+
false,
67+
);
68+
assert.equal(shouldRequestReview(enoughNotes, '1.0', now), false);
69+
assert.equal(shouldRequestReview(handled, '1.0', now), true);
70+
});
71+
72+
test('review is limited to once per app version', () => {
73+
const state = {
74+
...stateWithSaves(REVIEW_NOTE_THRESHOLD),
75+
communityPromptState: 'joined',
76+
communityHandledAt: new Date(Date.parse(startedAt)).toISOString(),
77+
reviewPromptedVersions: ['1.0'],
78+
};
79+
const now = Date.parse(startedAt) + REVIEW_MIN_AGE_MS;
80+
assert.equal(shouldRequestReview(state, '1.0', now), false);
81+
assert.equal(shouldRequestReview(state, '1.1', now), true);
82+
});
83+
84+
test('normalization repairs corrupt fields and bounds saved note ids', () => {
85+
const normalized = normalizeLifecycleState(
86+
{
87+
firstSeenAt: 'not-a-date',
88+
savedNoteIds: ['a', 'a', 'b', 'c', 'd', 'e', 'f'],
89+
communityPromptState: 'unexpected',
90+
reviewPromptedVersions: ['1.0', '1.0', '1.1'],
91+
},
92+
startedAt,
93+
);
94+
95+
assert.equal(normalized.firstSeenAt, startedAt);
96+
assert.equal(normalized.savedNoteIds.length, REVIEW_NOTE_THRESHOLD);
97+
assert.equal(normalized.communityPromptState, 'pending');
98+
assert.deepEqual(normalized.reviewPromptedVersions, ['1.0', '1.1']);
99+
});
100+
101+
test('normalization recovers the legacy shown state after an interrupted prompt', () => {
102+
const normalized = normalizeLifecycleState(
103+
{
104+
...stateWithSaves(COMMUNITY_NOTE_THRESHOLD),
105+
communityPromptState: 'shown',
106+
},
107+
startedAt,
108+
);
109+
110+
assert.equal(normalized.communityPromptState, 'pending');
111+
assert.equal(shouldOfferCommunity(normalized), true);
112+
});

0 commit comments

Comments
 (0)