diff --git a/app/folder/[id].tsx b/app/folder/[id].tsx index cf8d65a..4261fbe 100644 --- a/app/folder/[id].tsx +++ b/app/folder/[id].tsx @@ -26,7 +26,6 @@ import { listFolders, renameFolder, } from '../../src/services/foldersRepo'; -import { recordReviewSignal } from '../../src/services/reviewPromptService'; import type { BackgroundType, FolderMetadata, NoteMetadata } from '../../src/types/note'; type Action = @@ -82,14 +81,12 @@ export default function FolderScreen() { backgroundType, title: title.trim() || undefined, }); - void recordReviewSignal('note_created'); router.push(`/note/${meta.id}`); return; } const meta = await createPdfNoteFromPicker({ folderId: folder.id, title }); if (meta) { - void recordReviewSignal('note_created'); router.push(`/note/${meta.id}`); } } catch (error) { @@ -168,7 +165,6 @@ export default function FolderScreen() { key={note.id} note={note} onPress={() => { - void recordReviewSignal('note_opened'); router.push(`/note/${note.id}`); }} onLongPress={() => { diff --git a/app/index.tsx b/app/index.tsx index bf12ef0..06abcd2 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -2,17 +2,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Alert, - Linking, - Pressable, ScrollView, StyleSheet, - Text, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useFocusEffect, useRouter } from 'expo-router'; import * as Haptics from 'expo-haptics'; -import { Ionicons } from '@expo/vector-icons'; import { LibraryHeader } from '../src/components/library/LibraryHeader'; import { NewItemFAB } from '../src/components/library/NewItemFAB'; import { FolderCard } from '../src/components/library/FolderCard'; @@ -22,10 +18,14 @@ import { ItemActionsMenu } from '../src/components/library/ItemActionsMenu'; import { RenameDialog } from '../src/components/library/RenameDialog'; import { FolderPickerSheet } from '../src/components/library/FolderPickerSheet'; import { CreateNoteBackgroundSheet } from '../src/components/library/CreateNoteBackgroundSheet'; -import { Sheet } from '../src/components/ui/Sheet'; +import { OnboardingExperience } from '../src/components/onboarding/OnboardingExperience'; +import { CommunityInviteSheet } from '../src/components/library/CommunityInviteSheet'; +import { OpenNotesSheet } from '../src/components/library/OpenNotesSheet'; +import { LibrarySection } from '../src/components/library/LibrarySection'; +import { useOnboarding } from '../src/hooks/useOnboarding'; +import { useLibrarySupport } from '../src/hooks/useLibrarySupport'; import { useTheme } from '../src/hooks/useTheme'; import { spacing } from '../src/theme/spacing'; -import { typography } from '../src/theme/typography'; import { createNote, deleteNote, @@ -40,23 +40,12 @@ import { listFolders, renameFolder, } from '../src/services/foldersRepo'; -import { - recordReviewSignal, - requestReviewAfterPositiveMoment, -} from '../src/services/reviewPromptService'; import type { BackgroundType, FolderMetadata, NoteMetadata } from '../src/types/note'; -const LEGAL_URLS = { - privacy: 'https://mathnotes-app.github.io/OpenNotes/privacy/', - terms: 'https://mathnotes-app.github.io/OpenNotes/terms/', - support: 'https://mathnotes-app.github.io/OpenNotes/support/', - github: 'https://github.com/mathnotes-app/OpenNotes', - x: 'https://x.com/markpm39', -}; - type Action = | { kind: 'newItem' } - | { kind: 'about' } + | { kind: 'openNotes' } + | { kind: 'community' } | { kind: 'createNoteBackground' } | { kind: 'noteMenu'; note: NoteMetadata } | { kind: 'folderMenu'; folder: FolderMetadata } @@ -73,6 +62,7 @@ export default function LibraryScreen() { const [loading, setLoading] = useState(true); const [action, setAction] = useState(null); const creatingNoteRef = useRef(false); + const onboarding = useOnboarding(); const refresh = useCallback(async () => { const [allNotes, allFolders] = await Promise.all([ @@ -91,10 +81,21 @@ export default function LibraryScreen() { useFocusEffect( useCallback(() => { void refresh(); - void requestReviewAfterPositiveMoment(); }, [refresh]), ); + const closeSupport = useCallback(() => setAction(null), []); + const showCommunity = useCallback( + () => setAction({ kind: 'community' }), + [], + ); + const { dismissCommunity, joinCommunity, rateOpenNotes } = useLibrarySupport({ + canShowAutomaticPrompt: + onboarding.ready && !onboarding.visible && action === null, + onClose: closeSupport, + onShowCommunity: showCommunity, + }); + const rootNotes = useMemo( () => notes @@ -117,7 +118,6 @@ export default function LibraryScreen() { const openNote = useCallback( (id: string) => { void Haptics.selectionAsync(); - void recordReviewSignal('note_opened'); router.push(`/note/${id}`); }, [router], @@ -142,14 +142,12 @@ export default function LibraryScreen() { backgroundType, title: title.trim() || undefined, }); - void recordReviewSignal('note_created'); openNote(meta.id); return; } const meta = await createPdfNoteFromPicker({ folderId: null, title }); if (meta) { - void recordReviewSignal('note_created'); openNote(meta.id); } } catch (error) { @@ -234,41 +232,20 @@ export default function LibraryScreen() { ); }, [refresh]); - const openUrl = useCallback(async (url: string) => { - try { - await Linking.openURL(url); - } catch (error) { - if (__DEV__) console.warn('[LibraryScreen] open link failed', error); - Alert.alert('Could not open link', 'Please try again.'); - } - }, []); - return ( void openUrl(LEGAL_URLS.github), - }, - { - key: 'x', - icon: 'logo-x', - accessibilityLabel: 'Open Mark Miller on X', - onPress: () => void openUrl(LEGAL_URLS.x), - }, - { - key: 'about', - icon: 'information-circle-outline', - accessibilityLabel: 'About OpenNotes', - onPress: () => setAction({ kind: 'about' }), + key: 'openNotes', + icon: 'heart-outline', + accessibilityLabel: 'Support OpenNotes', + onPress: () => setAction({ kind: 'openNotes' }), }, ]} /> - {loading ? ( + {loading || !onboarding.ready ? ( @@ -284,7 +261,7 @@ export default function LibraryScreen() { showsVerticalScrollIndicator={false} > {sortedFolders.length > 0 ? ( -
+ {sortedFolders.map((folder) => ( ))} -
+ ) : null} {rootNotes.length > 0 ? ( -
0 ? 'Notes' : ''} theme={theme}> + 0 ? 'Notes' : ''}> {rootNotes.map((note) => ( ))} -
+ ) : null} )} setAction({ kind: 'newItem' })} /> - setAction(null)} + onJoinCommunity={() => void joinCommunity()} + onRate={() => void rateOpenNotes()} + onViewIntroduction={() => { + setAction(null); + onboarding.show(); + }} + /> + + void dismissCommunity()} + onJoin={() => void joinCommunity()} + /> + + void; -}) { - const theme = useTheme(); - const openUrl = useCallback(async (url: string) => { - try { - await Linking.openURL(url); - } catch (error) { - if (__DEV__) console.warn('[AboutSheet] open link failed', error); - Alert.alert('Could not open link', 'Please try again.'); - } - }, []); - - return ( - - - - - - - - About OpenNotes - - - Simple notes, open foundations. - - - - - - OpenNotes is meant to be a no-bloat, simple, free, open source notes - app. Most notes apps collect years of extra features, put important - tools behind a subscription, and keep the underlying ink technology - closed source. - - - It is also local and privacy focused: we do not collect anything, and - your notes never leave your device. - - - This app is starting from the opposite idea: keep the experience focused, - make the core technology inspectable, and build only what actually helps - people write. - - - OpenNotes is powered by the open source Mobile Ink engine. - - - void openUrl(LEGAL_URLS.privacy)} /> - void openUrl(LEGAL_URLS.terms)} /> - void openUrl(LEGAL_URLS.support)} /> - - - ); -} - -function AboutLink({ label, onPress }: { label: string; onPress: () => void }) { - const theme = useTheme(); - return ( - [ - styles.aboutLink, - { borderColor: theme.colors.divider }, - pressed && { opacity: 0.65 }, - ]} - > - - {label} - - - ); -} - -function Section({ - title, - theme, - children, -}: { - title: string; - theme: ReturnType; - children: React.ReactNode; -}) { - return ( - - {title ? ( - - {title} - - ) : null} - {children} - - ); -} - const styles = StyleSheet.create({ flex: { flex: 1 }, loader: { @@ -612,41 +495,4 @@ const styles = StyleSheet.create({ columnGap: spacing.md, rowGap: spacing.lg, }, - aboutHeader: { - alignItems: 'center', - flexDirection: 'row', - marginBottom: spacing.lg, - }, - aboutIcon: { - alignItems: 'center', - borderRadius: 18, - height: 36, - justifyContent: 'center', - marginRight: spacing.md, - width: 36, - }, - aboutTitleBlock: { - flex: 1, - }, - aboutBody: { - lineHeight: 22, - marginBottom: spacing.md, - }, - aboutLinks: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.sm, - marginTop: spacing.xs, - }, - aboutLink: { - alignItems: 'center', - borderRadius: 16, - borderWidth: StyleSheet.hairlineWidth, - minHeight: 34, - justifyContent: 'center', - paddingHorizontal: spacing.md, - }, - aboutLinkText: { - fontWeight: '600', - }, }); diff --git a/app/note/[id].tsx b/app/note/[id].tsx index 46f1c49..440d66c 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -57,7 +57,7 @@ import { type PickedImageResult, } from '../../src/services/imageInsertStorage'; import { exportNotebookAsPdf } from '../../src/services/exportService'; -import { recordReviewSignal } from '../../src/services/reviewPromptService'; +import { recordSuccessfulNoteSave } from '../../src/services/lifecycleService'; import { textBoxId, insertedElementId } from '../../src/utils/id'; import type { NoteMetadata } from '../../src/types/note'; import type { ToolDescriptor } from '../../src/utils/toolPalette'; @@ -206,8 +206,11 @@ export default function NoteScreen() { ...canvasData, pages: mergedPages, }; - await saveNoteBody(id, merged); - void recordReviewSignal('note_saved'); + const result = await saveNoteBody(id, merged); + if (!result.ok) { + throw new Error('Note body storage did not complete successfully.'); + } + await recordSuccessfulNoteSave(id); }, [id, mergeStoredPreviews, rememberPagePreviews]); const [autosaveEnabled, setAutosaveEnabled] = useState(true); @@ -675,8 +678,6 @@ export default function NoteScreen() { 'Export failed', result.error ?? 'Could not generate a PDF. Please try again.', ); - } else { - void recordReviewSignal('note_exported'); } } catch (error) { if (__DEV__) console.warn('[NoteScreen] export failed', error); diff --git a/assets/onboarding/help-it-grow.png b/assets/onboarding/help-it-grow.png new file mode 100644 index 0000000..f2e6fd9 Binary files /dev/null and b/assets/onboarding/help-it-grow.png differ diff --git a/assets/onboarding/private-by-design.png b/assets/onboarding/private-by-design.png new file mode 100644 index 0000000..8728f5c Binary files /dev/null and b/assets/onboarding/private-by-design.png differ diff --git a/assets/onboarding/write-freely.png b/assets/onboarding/write-freely.png new file mode 100644 index 0000000..a33ec50 Binary files /dev/null and b/assets/onboarding/write-freely.png differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4352aad..3026d95 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -257,7 +257,7 @@ PODS: - hermes-engine (0.81.5): - hermes-engine/Pre-built (= 0.81.5) - hermes-engine/Pre-built (0.81.5) - - MathNotesMobileInk (0.3.1): + - MathNotesMobileInk (0.3.2): - React-Core - react-native-skia - RCTDeprecation (0.81.5) @@ -2546,7 +2546,7 @@ SPEC CHECKSUMS: EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 - MathNotesMobileInk: 344466b9422b741c400619302265642d7b572bd1 + MathNotesMobileInk: 397dd2c1d132a84b594547f911a625cd667f77e9 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c diff --git a/package.json b/package.json index c4bc5d6..e3d89f7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "start": "expo start", "ios": "expo run:ios", "android": "expo run:android", + "test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/scripts/lifecyclePolicy.test.mjs b/scripts/lifecyclePolicy.test.mjs new file mode 100644 index 0000000..2ef5187 --- /dev/null +++ b/scripts/lifecyclePolicy.test.mjs @@ -0,0 +1,112 @@ +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'; + +function stateWithSaves(count) { + let state = createLifecycleState(startedAt); + for (let index = 0; index < count; index += 1) { + state = recordUniqueNoteSave( + state, + `note-${index}`, + new Date(Date.parse(startedAt) + index * 1000).toISOString(), + ); + } + return state; +} + +test('community prompt becomes eligible on the third unique saved note', () => { + assert.equal(shouldOfferCommunity(stateWithSaves(COMMUNITY_NOTE_THRESHOLD - 1)), false); + assert.equal(shouldOfferCommunity(stateWithSaves(COMMUNITY_NOTE_THRESHOLD)), true); +}); + +test('repeated saves of one note do not advance milestones', () => { + let state = createLifecycleState(startedAt); + for (let index = 0; index < 20; index += 1) { + state = recordUniqueNoteSave(state, 'same-note', startedAt); + } + assert.deepEqual(state.savedNoteIds, ['same-note']); + assert.equal(shouldOfferCommunity(state), false); +}); + +test('community prompt never returns after it is resolved', () => { + const eligible = stateWithSaves(COMMUNITY_NOTE_THRESHOLD); + for (const communityPromptState of ['joined', 'dismissed']) { + assert.equal( + shouldOfferCommunity({ ...eligible, communityPromptState }), + false, + ); + } +}); + +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.communityPromptState, 'pending'); + assert.deepEqual(normalized.reviewPromptedVersions, ['1.0', '1.1']); +}); + +test('normalization recovers the legacy shown state after an interrupted prompt', () => { + const normalized = normalizeLifecycleState( + { + ...stateWithSaves(COMMUNITY_NOTE_THRESHOLD), + communityPromptState: 'shown', + }, + startedAt, + ); + + assert.equal(normalized.communityPromptState, 'pending'); + assert.equal(shouldOfferCommunity(normalized), true); +}); diff --git a/src/components/library/CommunityInviteSheet.tsx b/src/components/library/CommunityInviteSheet.tsx new file mode 100644 index 0000000..cfd09c5 --- /dev/null +++ b/src/components/library/CommunityInviteSheet.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTheme } from '../../hooks/useTheme'; +import { radius, spacing } from '../../theme/spacing'; +import { Sheet } from '../ui/Sheet'; + +export function CommunityInviteSheet({ + visible, + onClose, + onJoin, +}: { + visible: boolean; + onClose: () => void; + onJoin: () => void; +}) { + const theme = useTheme(); + + return ( + + + + + + + + + Help shape OpenNotes. + + + Share ideas, vote on what comes next, and meet people who believe notes + should stay free. + + [ + styles.primaryButton, + { + backgroundColor: theme.colors.accent, + borderBottomColor: theme.isDark ? '#0056B3' : '#0066CC', + shadowColor: theme.colors.accent, + }, + pressed && styles.primaryPressed, + ]} + > + + Join the community + + + [styles.laterButton, pressed && styles.pressed]} + > + + Not now + + + + + ); +} + +const styles = StyleSheet.create({ + content: { paddingBottom: spacing.xs }, + iconRow: { marginBottom: spacing.lg }, + icon: { + alignItems: 'center', + borderRadius: radius.md, + height: 44, + justifyContent: 'center', + width: 44, + }, + title: { + fontSize: 30, + fontWeight: '600', + letterSpacing: -1, + }, + body: { + fontSize: 16, + marginTop: spacing.md, + maxWidth: 520, + }, + primaryButton: { + alignItems: 'center', + borderBottomWidth: 4, + borderRadius: radius.lg, + justifyContent: 'center', + marginTop: spacing.xl, + minHeight: 58, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.2, + shadowRadius: 16, + }, + primaryPressed: { + borderBottomWidth: 2, + opacity: 0.9, + transform: [{ translateY: 2 }], + }, + primaryText: { + color: '#FFFFFF', + fontSize: 17, + fontWeight: '600', + }, + laterButton: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 48, + }, + laterText: { + fontSize: 15, + fontWeight: '500', + }, + pressed: { opacity: 0.62 }, +}); diff --git a/src/components/library/LibrarySection.tsx b/src/components/library/LibrarySection.tsx new file mode 100644 index 0000000..2d10ab4 --- /dev/null +++ b/src/components/library/LibrarySection.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useTheme } from '../../hooks/useTheme'; +import { spacing } from '../../theme/spacing'; +import { typography } from '../../theme/typography'; + +export function LibrarySection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + const theme = useTheme(); + return ( + + {title ? ( + {title} + ) : null} + {children} + + ); +} + +const styles = StyleSheet.create({ + section: { marginBottom: spacing.lg }, + title: { + ...typography.footnote, + letterSpacing: 1, + marginBottom: spacing.sm, + paddingHorizontal: spacing.lg, + textTransform: 'uppercase', + }, +}); diff --git a/src/components/library/OpenNotesSheet.tsx b/src/components/library/OpenNotesSheet.tsx new file mode 100644 index 0000000..fbf730c --- /dev/null +++ b/src/components/library/OpenNotesSheet.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTheme } from '../../hooks/useTheme'; +import { radius, spacing } from '../../theme/spacing'; +import { typography } from '../../theme/typography'; +import { Sheet } from '../ui/Sheet'; +import { OPEN_NOTES_LINKS, openExternalLink } from '../../services/externalLinks'; + +interface OpenNotesSheetProps { + visible: boolean; + onClose: () => void; + onJoinCommunity: () => void; + onRate: () => void; + onViewIntroduction: () => void; +} + +export function OpenNotesSheet({ + visible, + onClose, + onJoinCommunity, + onRate, + onViewIntroduction, +}: OpenNotesSheetProps) { + const theme = useTheme(); + + return ( + + + + Free notes need a little help. + + + Everything here is optional. Writing notes never depends on it. + + + + + + { + void openExternalLink(OPEN_NOTES_LINKS.github, 'OpenNotesSheet'); + }} + /> + + + + + { + void openExternalLink(OPEN_NOTES_LINKS.privacy, 'OpenNotesSheet'); + }} + /> + { + void openExternalLink(OPEN_NOTES_LINKS.terms, 'OpenNotesSheet'); + }} + /> + { + void openExternalLink(OPEN_NOTES_LINKS.support, 'OpenNotesSheet'); + }} + /> + + + + ); +} + +function SupportRow({ + icon, + title, + subtitle, + onPress, + isLast = false, +}: { + icon: keyof typeof Ionicons.glyphMap; + title: string; + subtitle: string; + onPress: () => void; + isLast?: boolean; +}) { + const theme = useTheme(); + return ( + [ + styles.row, + !isLast && { + borderBottomColor: theme.colors.divider, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + pressed && { backgroundColor: theme.colors.surfaceMuted }, + ]} + > + + + + + + {title} + + + {subtitle} + + + + + ); +} + +function UtilityLink({ label, onPress }: { label: string; onPress: () => void }) { + const theme = useTheme(); + return ( + [styles.utilityLink, pressed && { opacity: 0.62 }]} + > + + {label} + + + ); +} + +const styles = StyleSheet.create({ + content: { paddingBottom: spacing.xs }, + title: { + fontSize: 30, + fontWeight: '600', + letterSpacing: -1, + }, + subtitle: { + fontSize: 15, + marginTop: spacing.sm, + }, + actions: { + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + marginTop: spacing.xl, + overflow: 'hidden', + }, + row: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.md, + minHeight: 68, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + rowIcon: { + alignItems: 'center', + borderRadius: radius.md, + height: 38, + justifyContent: 'center', + width: 38, + }, + rowCopy: { flex: 1 }, + rowTitle: { + fontSize: 15, + fontWeight: '600', + }, + rowSubtitle: { + fontSize: 12, + marginTop: 2, + }, + utilityLinks: { + flexDirection: 'row', + justifyContent: 'center', + marginTop: spacing.lg, + }, + utilityLink: { + justifyContent: 'center', + minHeight: 40, + paddingHorizontal: spacing.md, + }, +}); diff --git a/src/components/onboarding/OnboardingExperience.tsx b/src/components/onboarding/OnboardingExperience.tsx new file mode 100644 index 0000000..317284b --- /dev/null +++ b/src/components/onboarding/OnboardingExperience.tsx @@ -0,0 +1,392 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + FlatList, + Image, + ListRenderItemInfo, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + useWindowDimensions, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../../hooks/useTheme'; +import { radius, spacing } from '../../theme/spacing'; + +const SLIDES = [ + { + key: 'free', + title: 'Notes should\nbe free.', + body: 'So OpenNotes is. Write by hand, mark up PDFs, and keep every page without a subscription.', + image: require('../../../assets/onboarding/write-freely.png'), + imageLabel: 'Paper and an aluminum stylus drawing a blue line', + }, + { + key: 'privacy', + title: 'And they should\nstay yours.', + body: 'No account. No tracking. Your notes stay on your device until you decide otherwise.', + image: require('../../../assets/onboarding/private-by-design.png'), + imageLabel: 'A note secured inside a glass archival case', + }, + { + key: 'mission', + title: 'Built for everyone.', + body: 'OpenNotes is free, private, and built in the open. That is the promise.', + image: require('../../../assets/onboarding/help-it-grow.png'), + imageLabel: 'An open notebook with three woven bookmarks meeting at its binding', + }, +] as const; + +export interface OnboardingExperienceProps { + visible: boolean; + onComplete: () => void | Promise; +} + +export function OnboardingExperience({ + visible, + onComplete, +}: OnboardingExperienceProps) { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const { width, height, fontScale } = useWindowDimensions(); + const listRef = useRef>(null); + const [page, setPage] = useState(0); + + useEffect(() => { + if (!visible) return; + setPage(0); + requestAnimationFrame(() => { + listRef.current?.scrollToOffset({ offset: 0, animated: false }); + }); + }, [visible]); + + const finish = useCallback(() => { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + void onComplete(); + }, [onComplete]); + + const next = useCallback(() => { + if (page === SLIDES.length - 1) { + finish(); + return; + } + const nextPage = page + 1; + void Haptics.selectionAsync(); + setPage(nextPage); + listRef.current?.scrollToIndex({ index: nextPage, animated: true }); + }, [finish, page]); + + const renderSlide = useCallback( + ({ item, index }: ListRenderItemInfo<(typeof SLIDES)[number]>) => ( + + ), + [fontScale, height, insets.bottom, insets.top, page, width], + ); + + return ( + + + + + {SLIDES.map((slide, index) => ( + + ))} + + [styles.skip, pressed && styles.pressed]} + > + + Skip + + + + + item.key} + horizontal + pagingEnabled + bounces={false} + showsHorizontalScrollIndicator={false} + onMomentumScrollEnd={(event) => { + setPage(Math.round(event.nativeEvent.contentOffset.x / width)); + }} + getItemLayout={(_, index) => ({ + length: width, + offset: width * index, + index, + })} + /> + + + [ + styles.primaryButton, + { + backgroundColor: theme.colors.accent, + borderBottomColor: theme.isDark ? '#0056B3' : '#0066CC', + shadowColor: theme.colors.accent, + }, + pressed && styles.primaryPressed, + ]} + > + + {page === SLIDES.length - 1 ? 'Start writing' : 'Continue'} + + + + + + + ); +} + +function OnboardingSlide({ + active, + fontScale, + item, + viewportHeight, + width, +}: { + active: boolean; + fontScale: number; + item: (typeof SLIDES)[number]; + viewportHeight: number; + width: number; +}) { + const theme = useTheme(); + const isAccessibilityLayout = fontScale >= 1.5; + const isLandscape = width > viewportHeight && !isAccessibilityLayout; + const artHeight = useMemo( + () => + isAccessibilityLayout + ? Math.min(width * 0.28, viewportHeight * 0.28, 220) + : isLandscape + ? Math.min(width * 0.38, viewportHeight * 0.62, 300) + : Math.min(width - spacing.xl * 2, viewportHeight * 0.5, 440), + [isAccessibilityLayout, isLandscape, viewportHeight, width], + ); + + return ( + + + + + + + {item.title} + + + {item.body} + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + header: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.md, + minHeight: 44, + paddingHorizontal: spacing.xl, + }, + progress: { + flex: 1, + flexDirection: 'row', + gap: spacing.sm, + }, + progressSegment: { + borderRadius: radius.pill, + flex: 1, + height: 4, + }, + skip: { + justifyContent: 'center', + minHeight: 44, + paddingLeft: spacing.sm, + }, + skipText: { + fontSize: 15, + fontWeight: '500', + }, + pressed: { opacity: 0.62 }, + slideContent: { + flexGrow: 1, + justifyContent: 'flex-end', + paddingHorizontal: spacing.xl, + }, + landscapeSlideContent: { + alignItems: 'center', + flexDirection: 'row', + gap: spacing.xl, + justifyContent: 'center', + }, + artwork: { + alignSelf: 'center', + justifyContent: 'center', + marginBottom: spacing.lg, + width: '100%', + }, + landscapeArtwork: { + flex: 1, + marginBottom: 0, + maxWidth: 420, + }, + image: { height: '100%', width: '100%' }, + copy: { + marginTop: 'auto', + maxWidth: 520, + paddingBottom: spacing.xl, + width: '100%', + }, + landscapeCopy: { + flex: 1, + marginTop: 0, + maxWidth: 440, + paddingBottom: 0, + }, + title: { + fontSize: 39, + fontWeight: '600', + letterSpacing: -1.5, + }, + landscapeTitle: { + fontSize: 31, + letterSpacing: -1, + }, + body: { + fontSize: 17, + marginTop: spacing.md, + maxWidth: 480, + }, + landscapeBody: { + fontSize: 15, + }, + footer: { + paddingHorizontal: spacing.xl, + }, + primaryButton: { + alignItems: 'center', + borderBottomWidth: 4, + borderRadius: radius.lg, + flexDirection: 'row', + gap: spacing.sm, + justifyContent: 'center', + minHeight: 58, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.2, + shadowRadius: 16, + }, + primaryPressed: { + borderBottomWidth: 2, + opacity: 0.9, + transform: [{ translateY: 2 }], + }, + primaryButtonText: { + color: '#FFFFFF', + fontSize: 17, + fontWeight: '600', + letterSpacing: -0.2, + }, +}); diff --git a/src/components/ui/Sheet.tsx b/src/components/ui/Sheet.tsx index d5ba82c..fb7f9d3 100644 --- a/src/components/ui/Sheet.tsx +++ b/src/components/ui/Sheet.tsx @@ -67,6 +67,7 @@ export function Sheet({ visible, onClose, children }: SheetProps) { styles.card, { backgroundColor: theme.colors.surfaceElevated, + maxHeight: height - insets.top - spacing.md, paddingBottom: insets.bottom + spacing.lg, shadowColor: theme.colors.cardShadow, }, diff --git a/src/hooks/useLibrarySupport.ts b/src/hooks/useLibrarySupport.ts new file mode 100644 index 0000000..b88b81d --- /dev/null +++ b/src/hooks/useLibrarySupport.ts @@ -0,0 +1,100 @@ +import { useCallback, useRef } from 'react'; +import { Alert } from 'react-native'; +import { useFocusEffect } from 'expo-router'; +import { OPEN_NOTES_LINKS, openExternalLink } from '../services/externalLinks'; +import { + claimCommunityPrompt, + requestAutomaticReviewIfEligible, + requestManualReview, + resolveCommunityPrompt, +} from '../services/lifecycleService'; + +interface LibrarySupportOptions { + canShowAutomaticPrompt: boolean; + onClose: () => void; + onShowCommunity: () => void; +} + +export function useLibrarySupport({ + canShowAutomaticPrompt, + onClose, + onShowCommunity, +}: LibrarySupportOptions) { + const actionInFlightRef = useRef(false); + + useFocusEffect( + useCallback(() => { + let active = true; + if (!canShowAutomaticPrompt) { + return () => { + active = false; + }; + } + + void (async () => { + try { + if (await claimCommunityPrompt()) { + if (active) onShowCommunity(); + return; + } + await requestAutomaticReviewIfEligible(); + } catch (error) { + if (__DEV__) console.warn('[useLibrarySupport] prompt failed', error); + } + })(); + + return () => { + active = false; + }; + }, [canShowAutomaticPrompt, onShowCommunity]), + ); + + const dismissCommunity = useCallback(async () => { + onClose(); + try { + await resolveCommunityPrompt('dismissed'); + } catch (error) { + if (__DEV__) console.warn('[useLibrarySupport] dismissal failed', error); + } + }, [onClose]); + + const joinCommunity = useCallback(async () => { + if (actionInFlightRef.current) return; + actionInFlightRef.current = true; + try { + const opened = await openExternalLink( + OPEN_NOTES_LINKS.community, + 'useLibrarySupport', + ); + if (!opened) return; + await resolveCommunityPrompt('joined'); + onClose(); + } catch (error) { + if (__DEV__) console.warn('[useLibrarySupport] join failed', error); + Alert.alert('Could not update community status', 'Please try again.'); + } finally { + actionInFlightRef.current = false; + } + }, [onClose]); + + const rateOpenNotes = useCallback(async () => { + if (actionInFlightRef.current) return; + actionInFlightRef.current = true; + try { + const requested = await requestManualReview(); + if (!requested) { + Alert.alert( + 'Ratings are not available yet', + 'The rating option becomes available when OpenNotes is installed from an app store.', + ); + } + } catch (error) { + if (__DEV__) console.warn('[useLibrarySupport] review failed', error); + Alert.alert('Could not open ratings', 'Please try again later.'); + } finally { + actionInFlightRef.current = false; + } + }, []); + + return { dismissCommunity, joinCommunity, rateOpenNotes }; +} diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts new file mode 100644 index 0000000..2eccbf3 --- /dev/null +++ b/src/hooks/useOnboarding.ts @@ -0,0 +1,32 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + completeOnboarding, + hasCompletedOnboarding, +} from '../services/onboardingService'; + +export function useOnboarding() { + const [ready, setReady] = useState(false); + const [visible, setVisible] = useState(false); + + useEffect(() => { + void hasCompletedOnboarding() + .then((complete) => setVisible(!complete)) + .catch((error) => { + if (__DEV__) console.warn('[useOnboarding] read state failed', error); + setVisible(true); + }) + .finally(() => setReady(true)); + }, []); + + const show = useCallback(() => setVisible(true), []); + const finish = useCallback(async () => { + setVisible(false); + try { + await completeOnboarding(); + } catch (error) { + if (__DEV__) console.warn('[useOnboarding] save state failed', error); + } + }, []); + + return { finish, ready, show, visible }; +} diff --git a/src/services/externalLinks.ts b/src/services/externalLinks.ts new file mode 100644 index 0000000..f9a6d64 --- /dev/null +++ b/src/services/externalLinks.ts @@ -0,0 +1,20 @@ +import { Alert, Linking } from 'react-native'; + +export const OPEN_NOTES_LINKS = { + community: 'https://discord.gg/VWKmgMgYu', + github: 'https://github.com/mathnotes-app/OpenNotes', + privacy: 'https://mathnotes-app.github.io/OpenNotes/privacy/', + support: 'https://mathnotes-app.github.io/OpenNotes/support/', + terms: 'https://mathnotes-app.github.io/OpenNotes/terms/', +} as const; + +export async function openExternalLink(url: string, source: string): Promise { + try { + await Linking.openURL(url); + return true; + } catch (error) { + if (__DEV__) console.warn(`[${source}] open link failed`, error); + Alert.alert('Could not open link', 'Please try again.'); + return false; + } +} diff --git a/src/services/lifecyclePolicy.ts b/src/services/lifecyclePolicy.ts new file mode 100644 index 0000000..368ca4c --- /dev/null +++ b/src/services/lifecyclePolicy.ts @@ -0,0 +1,112 @@ +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'; + +export interface LifecycleState { + firstSeenAt: string; + lastSuccessfulSaveAt: string | null; + savedNoteIds: string[]; + communityPromptState: CommunityPromptState; + communityHandledAt: string | null; + reviewPromptedVersions: string[]; +} + +export function createLifecycleState(now: string): LifecycleState { + return { + firstSeenAt: now, + lastSuccessfulSaveAt: null, + savedNoteIds: [], + communityPromptState: 'pending', + communityHandledAt: null, + reviewPromptedVersions: [], + }; +} + +export function normalizeLifecycleState( + value: Partial | null | undefined, + now: string, +): LifecycleState { + const fallback = createLifecycleState(now); + if (!value) return fallback; + + const communityPromptState = + value.communityPromptState === 'joined' || + value.communityPromptState === 'dismissed' + ? value.communityPromptState + : 'pending'; + + return { + firstSeenAt: isIsoDate(value.firstSeenAt) ? value.firstSeenAt : now, + lastSuccessfulSaveAt: isIsoDate(value.lastSuccessfulSaveAt) + ? value.lastSuccessfulSaveAt + : null, + savedNoteIds: uniqueStrings(value.savedNoteIds).slice(0, REVIEW_NOTE_THRESHOLD), + communityPromptState, + communityHandledAt: isIsoDate(value.communityHandledAt) + ? value.communityHandledAt + : null, + reviewPromptedVersions: uniqueStrings(value.reviewPromptedVersions), + }; +} + +export function recordUniqueNoteSave( + state: LifecycleState, + noteId: string, + now: string, +): LifecycleState { + const savedNoteIds = state.savedNoteIds.includes(noteId) + ? state.savedNoteIds + : [...state.savedNoteIds, noteId].slice(0, REVIEW_NOTE_THRESHOLD); + + return { + ...state, + lastSuccessfulSaveAt: now, + savedNoteIds, + }; +} + +export function shouldOfferCommunity(state: LifecycleState): boolean { + return ( + state.communityPromptState === 'pending' && + state.savedNoteIds.length >= COMMUNITY_NOTE_THRESHOLD + ); +} + +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)); +} + +function uniqueStrings(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.filter((item): item is string => typeof item === 'string'))]; +} diff --git a/src/services/lifecycleService.ts b/src/services/lifecycleService.ts new file mode 100644 index 0000000..10b074b --- /dev/null +++ b/src/services/lifecycleService.ts @@ -0,0 +1,120 @@ +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'; + +const LIFECYCLE_KEY = '@opennotes:lifecycle:v1'; +const APP_VERSION = Constants.expoConfig?.version ?? 'unknown'; + +let stateQueue: Promise = Promise.resolve(); +let reviewRequest: Promise | null = null; +let communityPromptClaimedThisSession = false; + +function withStateLock(operation: () => Promise): Promise { + const result = stateQueue.then(operation, operation); + stateQueue = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +async function readStateUnlocked(): Promise { + const now = new Date().toISOString(); + const raw = await AsyncStorage.getItem(LIFECYCLE_KEY); + if (!raw) return createLifecycleState(now); + + try { + return normalizeLifecycleState( + JSON.parse(raw) as Partial, + now, + ); + } catch (error) { + if (__DEV__) console.warn('[lifecycleService] invalid stored state', error); + return createLifecycleState(now); + } +} + +async function writeStateUnlocked(state: LifecycleState): Promise { + await AsyncStorage.setItem(LIFECYCLE_KEY, JSON.stringify(state)); +} + +export async function recordSuccessfulNoteSave(noteId: string): Promise { + if (!noteId) return; + await withStateLock(async () => { + const state = await readStateUnlocked(); + const next = recordUniqueNoteSave(state, noteId, new Date().toISOString()); + await writeStateUnlocked(next); + }); +} + +export async function claimCommunityPrompt(): Promise { + return withStateLock(async () => { + if (communityPromptClaimedThisSession) return false; + const state = await readStateUnlocked(); + if (!shouldOfferCommunity(state)) return false; + communityPromptClaimedThisSession = true; + return true; + }); +} + +export async function resolveCommunityPrompt( + resolution: Extract, +): Promise { + await withStateLock(async () => { + const state = await readStateUnlocked(); + await writeStateUnlocked({ + ...state, + communityPromptState: resolution, + communityHandledAt: new Date().toISOString(), + }); + }); +} + +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/onboardingService.ts b/src/services/onboardingService.ts new file mode 100644 index 0000000..3600498 --- /dev/null +++ b/src/services/onboardingService.ts @@ -0,0 +1,11 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const ONBOARDING_KEY = '@opennotes:onboarding:v1'; + +export async function hasCompletedOnboarding(): Promise { + return (await AsyncStorage.getItem(ONBOARDING_KEY)) === 'complete'; +} + +export async function completeOnboarding(): Promise { + await AsyncStorage.setItem(ONBOARDING_KEY, 'complete'); +} diff --git a/src/services/pdfImportService.ts b/src/services/pdfImportService.ts index 70fd752..f33300a 100644 --- a/src/services/pdfImportService.ts +++ b/src/services/pdfImportService.ts @@ -12,6 +12,7 @@ import { type PickedPdfResult, } from './pdfStorage'; import type { NoteMetadata } from '../types/note'; +import { recordSuccessfulNoteSave } from './lifecycleService'; interface PdfNoteOptions { folderId?: string | null; @@ -55,9 +56,16 @@ async function createPdfNote( const title = cleanTitle(options.title) || cleanTitle(result.name.replace(/\.pdf$/i, '')); const body = createPdfNotebookData(result.pageCount); - await saveNoteBody(meta.id, body); - await setNoteBackground(meta.id, 'pdf', result.uri); - return title ? renameNote(meta.id, title) : setNoteBackground(meta.id, 'pdf', result.uri); + const saveResult = await saveNoteBody(meta.id, body); + if (!saveResult.ok) { + throw new Error('Imported PDF note body could not be saved.'); + } + const withBackground = await setNoteBackground(meta.id, 'pdf', result.uri); + const finalMetadata = title + ? await renameNote(meta.id, title) + : withBackground; + await recordSuccessfulNoteSave(meta.id); + return finalMetadata; } catch (error) { await deleteNote(meta.id).catch(() => {}); throw error; diff --git a/src/services/reviewPromptService.ts b/src/services/reviewPromptService.ts deleted file mode 100644 index a1c8024..0000000 --- a/src/services/reviewPromptService.ts +++ /dev/null @@ -1,98 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import * as StoreReview from 'expo-store-review'; - -type ReviewSignal = 'note_created' | 'note_opened' | 'note_saved' | 'note_exported'; - -interface ReviewPromptState { - firstSeenAt: string; - lastPromptedAt: string | null; - promptCount: number; - notesCreated: number; - notesOpened: number; - notesSaved: number; - notesExported: number; - pendingPositiveMoment: boolean; -} - -const KEY = '@opennotes:reviewPrompt:v1'; -const MIN_SESSION_AGE_MS = 15 * 60 * 1000; -const PROMPT_COOLDOWN_MS = 120 * 24 * 60 * 60 * 1000; -const MAX_PROMPTS = 3; - -function initialState(now: string): ReviewPromptState { - return { - firstSeenAt: now, - lastPromptedAt: null, - promptCount: 0, - notesCreated: 0, - notesOpened: 0, - notesSaved: 0, - notesExported: 0, - pendingPositiveMoment: false, - }; -} - -async function readState(): Promise { - const now = new Date().toISOString(); - const raw = await AsyncStorage.getItem(KEY); - if (!raw) return initialState(now); - try { - return { ...initialState(now), ...(JSON.parse(raw) as Partial) }; - } catch { - return initialState(now); - } -} - -async function writeState(state: ReviewPromptState): Promise { - await AsyncStorage.setItem(KEY, JSON.stringify(state)); -} - -export async function recordReviewSignal(signal: ReviewSignal): Promise { - const state = await readState(); - switch (signal) { - case 'note_created': - state.notesCreated += 1; - break; - case 'note_opened': - state.notesOpened += 1; - break; - case 'note_saved': - state.notesSaved += 1; - break; - case 'note_exported': - state.notesExported += 1; - break; - } - state.pendingPositiveMoment = true; - await writeState(state); -} - -export async function requestReviewAfterPositiveMoment(): Promise { - const state = await readState(); - if (!state.pendingPositiveMoment || state.promptCount >= MAX_PROMPTS) return; - - const now = Date.now(); - const firstSeen = Date.parse(state.firstSeenAt); - const lastPrompted = state.lastPromptedAt ? Date.parse(state.lastPromptedAt) : 0; - if (Number.isFinite(firstSeen) && now - firstSeen < MIN_SESSION_AGE_MS) return; - if (lastPrompted && now - lastPrompted < PROMPT_COOLDOWN_MS) return; - - const steadyUse = state.notesSaved >= 5 && state.notesOpened >= 3; - const creatorUse = state.notesCreated >= 2 && state.notesSaved >= 3; - const exportSuccess = state.notesExported >= 1 && state.notesSaved >= 2; - if (!steadyUse && !creatorUse && !exportSuccess) return; - - const available = await StoreReview.isAvailableAsync(); - if (!available) return; - - const hasAction = await StoreReview.hasAction(); - if (!hasAction) return; - - await StoreReview.requestReview(); - await writeState({ - ...state, - pendingPositiveMoment: false, - promptCount: state.promptCount + 1, - lastPromptedAt: new Date().toISOString(), - }); -}