feat: persist mobile vault selection
This commit is contained in:
@@ -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 }
|
||||
|
||||
60
apps/mobile/src/mobileAppStateStorage.test.ts
Normal file
60
apps/mobile/src/mobileAppStateStorage.test.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
126
apps/mobile/src/mobileAppStateStorage.ts
Normal file
126
apps/mobile/src/mobileAppStateStorage.ts
Normal 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
|
||||
}
|
||||
6
apps/mobile/src/mobileNativeAppStateStorage.ts
Normal file
6
apps/mobile/src/mobileNativeAppStateStorage.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as ExpoFileSystem from 'expo-file-system/legacy'
|
||||
import { createMobileAppStateStorage } from './mobileAppStateStorage'
|
||||
|
||||
export function createNativeMobileAppStateStorage() {
|
||||
return createMobileAppStateStorage(ExpoFileSystem)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user