Phase 18: settings that belong to the writer, not the browser
The mute toggle, the falling-petals toggle and the chosen companion lived in localStorage, which is a property of the machine. Now that two people can sign in to one Petal, sharing a laptop would have meant sharing a mascot and one person's silence muting the other. Each key is namespaced by user id. The awkward part is timing: sounds.ts and petals.ts read their value the moment they are imported, long before /api/me can have answered. Rather than block startup on the network for a mute flag, a read before the answer arrives sees the old un-namespaced key -- on a single-writer browser, exactly the right value -- and setPrefsScope then adopts it into that account's namespace and tells every reader to look again. Adoption moves rather than copies, so the first account inherits what was set before accounts existed and the second starts from Petal's defaults. The personal spelling dictionary moves further than that: onto the server. It is built from her own writing, so it should not be readable by whoever sits down at the same browser next -- but merely namespacing it would have split the list she already has between her laptop and her tablet, which is worse than where we started. A table keyed (user_id, lang, word) follows her instead. The lang is the dictionary's, not hers: an English exception must not silence a pt-PT flag once the second pair ships. Adding a word takes effect in the editor immediately and persists in the background, so the underline goes away the instant she asks. A browser still holding the old list hands it over on first load, and only lets go once the server has taken it. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, onUnauthorized, type Me } from '../api/client'
|
||||
import { setPrefsScope } from '../lib/prefs'
|
||||
|
||||
// useSession tracks who is writing, and notices the moment the server stops
|
||||
// recognising them.
|
||||
@@ -18,7 +19,13 @@ export function useSession() {
|
||||
api
|
||||
.me()
|
||||
.then((user) => {
|
||||
if (!cancelled) setMe(user)
|
||||
if (cancelled) return
|
||||
// Browser preferences (mute, petals, companion) belong to the writer,
|
||||
// not the machine. This is the moment their storage keys can stop being
|
||||
// shared — and the first account on this browser inherits whatever was
|
||||
// set back when Petal had no accounts at all.
|
||||
setPrefsScope(user.id)
|
||||
setMe(user)
|
||||
})
|
||||
.catch(() => {
|
||||
// A 401 has already flipped signedOut through the interceptor; anything
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import nspell, { type NSpell } from 'nspell'
|
||||
import { api } from '../api/client'
|
||||
|
||||
// useSpellChecker loads the vendored en-US Hunspell dictionary (served from
|
||||
// /dictionaries/en, embedded in the Go binary via web/dist) and builds an
|
||||
// in-browser nspell instance — zero backend round-trips, per spec. The
|
||||
// dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS
|
||||
// bundle) once per app session, not per document. A personal word list lives in
|
||||
// localStorage and is replayed into nspell on load; adding a word bumps a
|
||||
// `version` so consumers re-run their decorations and the word stops flagging.
|
||||
// bundle) once per app session, not per document.
|
||||
//
|
||||
// The personal word list — the words she's told Petal to stop flagging — lives
|
||||
// on the server, keyed by her account and by the dictionary's language. It used
|
||||
// to be one localStorage key, which meant two people sharing a device shared a
|
||||
// word list built from one person's private writing, and one person on a laptop
|
||||
// and a tablet had two lists that never met. It is replayed into nspell on load;
|
||||
// adding a word bumps a `version` so consumers re-run their decorations and the
|
||||
// word stops flagging.
|
||||
|
||||
// SpellChecker is the minimal surface the editor decoration layer consumes.
|
||||
export interface SpellChecker {
|
||||
@@ -15,11 +22,21 @@ export interface SpellChecker {
|
||||
suggest(word: string): string[]
|
||||
}
|
||||
|
||||
const PERSONAL_KEY = 'petal.spell.personal'
|
||||
// The dictionary this hook loads. Only en-US ships today; pt-PT arrives with the
|
||||
// first Latin pair, and its personal words are a separate list by design — an
|
||||
// English exception must not silence a Portuguese flag.
|
||||
const DICT_LANG = 'en'
|
||||
|
||||
function loadPersonal(): string[] {
|
||||
// Where the list lived before it had an owner (Phase 7). Read once, handed to
|
||||
// the account, and then removed — see takeLegacyWords.
|
||||
const LEGACY_KEY = 'petal.spell.personal'
|
||||
|
||||
// takeLegacyWords reads the pre-account list without deleting it: the words are
|
||||
// only dropped from the browser once the server has actually accepted them, so
|
||||
// a failed request costs nothing.
|
||||
function readLegacyWords(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(PERSONAL_KEY)
|
||||
const raw = localStorage.getItem(LEGACY_KEY)
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(parsed) ? parsed.filter((w): w is string => typeof w === 'string') : []
|
||||
} catch {
|
||||
@@ -27,11 +44,11 @@ function loadPersonal(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function savePersonal(words: string[]) {
|
||||
function clearLegacyWords() {
|
||||
try {
|
||||
localStorage.setItem(PERSONAL_KEY, JSON.stringify(words))
|
||||
localStorage.removeItem(LEGACY_KEY)
|
||||
} catch {
|
||||
/* storage unavailable — personal words just won't persist this session */
|
||||
/* storage unavailable — nothing was read from it either */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +69,25 @@ export function useSpellChecker() {
|
||||
])
|
||||
if (cancelled) return
|
||||
const sp = nspell(aff, dic)
|
||||
for (const w of loadPersonal()) sp.add(w)
|
||||
spellRef.current = sp
|
||||
setReady(true)
|
||||
|
||||
// Her own words come from her account. A browser holding a list from
|
||||
// before accounts existed hands it over on the way — but only lets go of
|
||||
// it once the server has taken it.
|
||||
const legacy = readLegacyWords()
|
||||
const stored = legacy.length
|
||||
? await api.addPersonalWords(DICT_LANG, legacy).then((res) => {
|
||||
clearLegacyWords()
|
||||
return res
|
||||
})
|
||||
: await api.listPersonalWords(DICT_LANG)
|
||||
if (cancelled) return
|
||||
for (const w of stored.words) sp.add(w)
|
||||
setVersion((v) => v + 1)
|
||||
} catch (err) {
|
||||
// A failure here costs correct words being flagged, not writing. The
|
||||
// checker itself stays usable if only the word list failed to arrive.
|
||||
console.error('spell checker failed to load', err)
|
||||
}
|
||||
})()
|
||||
@@ -75,13 +107,17 @@ export function useSpellChecker() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, version])
|
||||
|
||||
// Adding a word takes effect in the editor immediately and is persisted in the
|
||||
// background: the word stops being underlined the instant she asks, whatever
|
||||
// the network is doing.
|
||||
const addWord = useCallback((word: string) => {
|
||||
const sp = spellRef.current
|
||||
if (!sp) return
|
||||
sp.add(word)
|
||||
const next = Array.from(new Set([...loadPersonal(), word]))
|
||||
savePersonal(next)
|
||||
setVersion((v) => v + 1)
|
||||
api.addPersonalWords(DICT_LANG, [word]).catch((err) => {
|
||||
console.error('could not save personal word', err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { checker, ready, addWord }
|
||||
|
||||
Reference in New Issue
Block a user