A review of the pair work found the seams — every one of them a place where
the Chinese half was written and the older Latin half was left standing.
The right-click menu still asked the Latin tokenizer whether there was a word
under the pointer, so right-clicking a hanzi opened the browser's own menu
instead of the card. Hover, long-press and Ctrl+D had all moved to the shared
resolver; this one hadn't, and it is the surface the segmenter's own header
names first.
isHan is a property escape precisely so the extension blocks are covered, and
then every call site handed it one UTF-16 code unit — half a surrogate pair
for anything above the BMP, which \p{Script=Han} rightly says is not Han. The
run split in two around the character and the words either side stopped being
looked up. The walk, the scan and wordAt now step by code point, the regex is
anchored, and the test that passed by accident (unanchored, so it searched a
two-unit string rather than testing one character) is joined by one that
would have failed.
The pair picker sent the pair alone. The server validates pair and direction
as one decision and refuses a learner direction for a pair it has no word
list for — so an English speaker learning Chinese could not move to French at
all: every button failed with the generic message. It now names both, keeps
her direction where the target pack has a learner side, and returns her to
learning_en where it does not. Routed through useSession rather than the
picker's own api call, so me.direction — which decides whether the word list
stays loaded — moves with it.
UpdateMe answered every error from Get with 401. A SQLite fault on a PATCH
would have tripped the client's session interceptor and thrown a writer into
the signed-out overlay while her session was fine. Only a missing row means
not signed in, which is the distinction SetPair already made below it.
emitCommittedRef was assigned during render and called later from
compositionend; a render React discards must not leave its closure behind for
a DOM event.
And the kitten is 10% smaller — one clamp, three terms, everything else
calc()s off it.
vitest 297/297, tsc, go build/vet/test clean.
69 lines
2.9 KiB
TypeScript
69 lines
2.9 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { api, onUnauthorized, type Me } from '../api/client'
|
|
import { setPrefsScope } from '../lib/prefs'
|
|
import { setPackLang } from '../i18n'
|
|
|
|
// useSession tracks who is writing, and notices the moment the server stops
|
|
// recognising them.
|
|
//
|
|
// `signedOut` going true is not an error state to report — it's a state to
|
|
// recover from: the app stops auto-saving, keeps the draft, and shows a warm
|
|
// invitation to sign in again. Every API call routes its 401 here through the
|
|
// client's single interceptor, so it fires once no matter which call noticed.
|
|
export function useSession() {
|
|
const [me, setMe] = useState<Me | null>(null)
|
|
const [signedOut, setSignedOut] = useState(false)
|
|
|
|
useEffect(() => {
|
|
onUnauthorized(() => setSignedOut(true))
|
|
let cancelled = false
|
|
api
|
|
.me()
|
|
.then((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)
|
|
// …and so does the language Petal speaks back. Until this point the app
|
|
// renders the default pack; a writer on another pair sees her own copy
|
|
// from here on, without a reload.
|
|
setPackLang(user.pair_lang)
|
|
setMe(user)
|
|
})
|
|
.catch(() => {
|
|
// A 401 has already flipped signedOut through the interceptor; anything
|
|
// else (the server briefly down) leaves `me` null, which only costs the
|
|
// display name.
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
// Turn the pair around. The account is the source of truth for which
|
|
// direction the editor is in — it decides whether the word list loads at all —
|
|
// so the state moves only once the server has agreed, and it moves to what the
|
|
// server *stored* rather than to what was asked for.
|
|
const setDirection = async (direction: string) => {
|
|
const updated = await api.setDirection(direction)
|
|
setMe(updated)
|
|
}
|
|
|
|
// Move the pair, naming the direction with it. The two are validated together
|
|
// server-side, so an account that is learning Chinese cannot change pair by
|
|
// sending `pair_lang` alone — the combination it would ask for (French with
|
|
// segmentation) does not exist and is refused. Saying both is how that move is
|
|
// made, and routing it through here rather than through the picker's own
|
|
// `api` call is what keeps `me.direction` — which decides whether the word
|
|
// list stays loaded — in step with what was actually stored.
|
|
const setPair = async (lang: string, direction: string) => {
|
|
const updated = await api.setPair(lang, direction)
|
|
setPackLang(updated.pair_lang)
|
|
setMe(updated)
|
|
}
|
|
|
|
return { me, signedOut, setDirection, setPair }
|
|
}
|