feat: persist mobile vault selection

This commit is contained in:
lucaronin
2026-05-05 00:24:47 +02:00
parent 46aa43c1cd
commit 19e017b4c6
5 changed files with 224 additions and 12 deletions

View File

@@ -39,11 +39,15 @@ import { styles } from './styles'
import { colors } from './theme'
import { MobileNoteCreatePrompt } from './MobileNoteCreatePrompt'
import { useMobileNoteCreateFlow } from './useMobileNoteCreateFlow'
import { createNativeMobileAppStateStorage } from './mobileNativeAppStateStorage'
const activeMobileVaultId = 'personal'
export function MobileApp() {
const { width } = useWindowDimensions()
const isTablet = width >= 820
const showsProperties = width >= 1120
const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), [])
const [availableNotes, setAvailableNotes] = useState(fallbackNotes)
const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(fallbackNotes[0].id))
const [saveStateByNoteId, setSaveStateByNoteId] = useState<Record<string, MobileEditorSaveState>>({})
@@ -70,11 +74,11 @@ export function MobileApp() {
useEffect(() => {
let isActive = true
loadDemoVaultNotes()
.then((loadedNotes) => {
Promise.all([loadDemoVaultNotes(), appStateStorage.load(activeMobileVaultId)])
.then(([loadedNotes, appState]) => {
if (isActive && loadedNotes.length > 0) {
setAvailableNotes(loadedNotes)
setCompactNavigation((state) => selectLoadedNote(state, loadedNotes))
setCompactNavigation((state) => selectLoadedNote(state, loadedNotes, appState.selectedNoteId))
}
})
.catch(() => {})
@@ -82,19 +86,21 @@ export function MobileApp() {
return () => {
isActive = false
}
}, [])
}, [appStateStorage])
useEffect(() => () => autosaveQueue.cancelAll(), [autosaveQueue])
const selectNote = (note: MobileNote) => {
setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id }))
}
const selectNoteId = useCallback((noteId: string) => {
setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId }))
void appStateStorage.save({ activeVaultId: activeMobileVaultId, selectedNoteId: noteId }).catch(() => {})
}, [appStateStorage])
const selectNote = useCallback((note: MobileNote) => selectNoteId(note.id), [selectNoteId])
const saveDraft = useCallback((draft: MobileEditorDraft) => autosaveQueue.enqueue(draft), [autosaveQueue])
const createFlow = useMobileNoteCreateFlow({
createNote: (title) => createDemoVaultNote({ title }),
onCreated: (note) => {
setAvailableNotes((notes) => [note, ...notes.filter((item) => item.id !== note.id)])
setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id }))
selectNoteId(note.id)
},
})
@@ -352,7 +358,15 @@ function NoteListPanel({
)
}
function selectLoadedNote(state: ReturnType<typeof createCompactNavigationState>, loadedNotes: MobileNote[]) {
function selectLoadedNote(
state: ReturnType<typeof createCompactNavigationState>,
loadedNotes: MobileNote[],
preferredNoteId: string | null,
) {
if (preferredNoteId && loadedNotes.some((note) => note.id === preferredNoteId)) {
return { ...state, selectedNoteId: preferredNoteId }
}
return loadedNotes.some((note) => note.id === state.selectedNoteId)
? state
: { ...state, selectedNoteId: loadedNotes[0].id }

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
createMobileAppStateStorage,
type MobileAppStateFileSystem,
} from './mobileAppStateStorage'
describe('mobile app state storage', () => {
it('returns default state when no app state file exists', async () => {
const storage = createMobileAppStateStorage(createMemoryAppStateFileSystem())
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: null,
})
})
it('persists and restores selected note id for the active vault', async () => {
const fileSystem = createMemoryAppStateFileSystem()
const storage = createMobileAppStateStorage(fileSystem)
await storage.save({ activeVaultId: 'personal', selectedNoteId: 'workflow' })
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: 'workflow',
})
})
it('ignores corrupt or mismatched state files', async () => {
const fileSystem = createMemoryAppStateFileSystem({
'file:///docs/state/app-state.json': '{"activeVaultId":"other","selectedNoteId":"workflow"}',
})
const storage = createMobileAppStateStorage(fileSystem)
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: null,
})
})
})
function createMemoryAppStateFileSystem(files: Record<string, string> = {}): MobileAppStateFileSystem {
const fileByUri = new Map(Object.entries(files))
const directoryUris = new Set(['file:///docs'])
return {
documentDirectory: 'file:///docs/',
getInfoAsync: async (uri) => ({
exists: fileByUri.has(uri) || directoryUris.has(uri),
isDirectory: directoryUris.has(uri),
}),
makeDirectoryAsync: async (uri) => {
directoryUris.add(uri)
},
readAsStringAsync: async (uri) => fileByUri.get(uri) ?? '',
writeAsStringAsync: async (uri, content) => {
fileByUri.set(uri, content)
},
}
}

View File

@@ -0,0 +1,126 @@
export type MobileAppState = {
activeVaultId: string
selectedNoteId: string | null
}
export type MobileAppStateFileInfo = {
exists: boolean
isDirectory?: boolean
}
export type MobileAppStateFileSystem = {
documentDirectory: string | null
getInfoAsync: (uri: string) => Promise<MobileAppStateFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string) => Promise<string>
writeAsStringAsync: (uri: string, content: string) => Promise<void>
}
export type MobileAppStateStorage = {
load: (activeVaultId: string) => Promise<MobileAppState>
save: (state: MobileAppState) => Promise<void>
}
export function createMobileAppStateStorage(
fileSystem: MobileAppStateFileSystem,
): MobileAppStateStorage {
return {
load: async (activeVaultId) => loadMobileAppState({ activeVaultId, fileSystem }),
save: async (state) => saveMobileAppState({ fileSystem, state }),
}
}
async function loadMobileAppState({
activeVaultId,
fileSystem,
}: {
activeVaultId: string
fileSystem: MobileAppStateFileSystem
}) {
const fileUri = appStateFileUri(fileSystem)
const info = await fileSystem.getInfoAsync(fileUri)
if (!info.exists || info.isDirectory) {
return defaultMobileAppState(activeVaultId)
}
return parseMobileAppState({
activeVaultId,
content: await fileSystem.readAsStringAsync(fileUri),
})
}
async function saveMobileAppState({
fileSystem,
state,
}: {
fileSystem: MobileAppStateFileSystem
state: MobileAppState
}) {
const rootUri = appStateRootUri(fileSystem)
await ensureDirectory({ fileSystem, uri: rootUri })
await fileSystem.writeAsStringAsync(appStateFileUri(fileSystem), JSON.stringify(state))
}
function parseMobileAppState({
activeVaultId,
content,
}: {
activeVaultId: string
content: string
}) {
try {
return coerceMobileAppState({ activeVaultId, value: JSON.parse(content) })
} catch {
return defaultMobileAppState(activeVaultId)
}
}
function coerceMobileAppState({
activeVaultId,
value,
}: {
activeVaultId: string
value: unknown
}): MobileAppState {
if (!isStateRecord(value) || value.activeVaultId !== activeVaultId) {
return defaultMobileAppState(activeVaultId)
}
return {
activeVaultId,
selectedNoteId: typeof value.selectedNoteId === 'string' ? value.selectedNoteId : null,
}
}
function defaultMobileAppState(activeVaultId: string): MobileAppState {
return { activeVaultId, selectedNoteId: null }
}
async function ensureDirectory({
fileSystem,
uri,
}: {
fileSystem: MobileAppStateFileSystem
uri: string
}) {
const info = await fileSystem.getInfoAsync(uri)
if (!info.exists) {
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
}
}
function appStateFileUri(fileSystem: MobileAppStateFileSystem) {
return `${appStateRootUri(fileSystem)}/app-state.json`
}
function appStateRootUri(fileSystem: MobileAppStateFileSystem) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state`
}
function isStateRecord(value: unknown): value is { activeVaultId?: unknown; selectedNoteId?: unknown } {
return typeof value === 'object' && value !== null
}

View File

@@ -0,0 +1,6 @@
import * as ExpoFileSystem from 'expo-file-system/legacy'
import { createMobileAppStateStorage } from './mobileAppStateStorage'
export function createNativeMobileAppStateStorage() {
return createMobileAppStateStorage(ExpoFileSystem)
}

View File

@@ -65,14 +65,15 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Added the first app-local mobile note creation path and wired the compose button to write a new Markdown note, prepend it to the current list, and select it through the shared compact navigation reducer.
- Added create-note UX state so the compose button disables during app-local creation and displays a compact failure message if storage creation fails.
- Added a title-entry prompt for mobile note creation so new notes are named before they are written to app-local storage.
- Added app-managed mobile state storage for the active vault and last selected note, then restored and persisted note selection across launches.
## Next Action
Continue Phase 3 with app-managed vault storage hardening:
1. Add local saved state for active vault and last selected note, then restore it on launch.
2. Add delete/archive support for local app-managed notes.
3. Add a focused simulator interaction path for create/open/edit/autosave once Expo Go's overlay no longer blocks clean screenshots.
1. Add delete/archive support for local app-managed notes.
2. Add a focused simulator interaction path for create/open/edit/autosave once Expo Go's overlay no longer blocks clean screenshots.
3. Add app-local vault metadata beyond the hardcoded demo vault id.
## Verification Log
@@ -221,6 +222,11 @@ Continue Phase 3 with app-managed vault storage hardening:
- CodeScene after title-entry note creation: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileNoteCreatePrompt.tsx`, `apps/mobile/src/useMobileNoteCreateFlow.ts`, `apps/mobile/src/mobileDemoVault.ts`, `apps/mobile/src/mobileNoteCreate.test.ts`, and `apps/mobile/src/styles/noteCreateStyles.ts` scored `10`.
- `pnpm --filter @tolaria/mobile test` passed after title-entry note creation: 17 files / 54 tests.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after title-entry note creation.
- `pnpm --filter @tolaria/mobile test -- src/mobileAppStateStorage.test.ts src/mobileNoteCreate.test.ts` passed after saved app state storage: 18 files / 57 tests.
- `pnpm --filter @tolaria/mobile typecheck` passed after saved app state storage.
- CodeScene after saved app state storage: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/mobileAppStateStorage.ts`, `apps/mobile/src/mobileAppStateStorage.test.ts`, and `apps/mobile/src/mobileNativeAppStateStorage.ts` scored `10`.
- `pnpm --filter @tolaria/mobile test` passed after saved app state storage: 18 files / 57 tests.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after saved app state storage.
## Risks / Watch Items