diff --git a/packages/ui/README.md b/packages/ui/README.md --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -35,6 +35,7 @@ | `src/lib/private-tabs.ts` | One transport per browser **profile**, shared by every tab of it (`docs/adr-private-mode-iroh.md` §26). The endpoint identity is a per-profile IndexedDB row, so two tabs binding it independently produced two endpoints with one node id and a relay routed to whichever registered last. `navigator.locks` elects one tab to bind; one `BroadcastChannel` relays the others' dials, frames and inbound-connection offers through it. The holder owns the **transport and not the replica** — every tab keeps its own `PrivateEndpoint`, bus, connections and ingestor over its own stores. A promotion re-reads the identity so the next holder is the same node id; relayed links are orphaned so they throw and the buses above redial. `MemoryTabNetwork` is several tabs in one process, for the tests. | | `src/lib/private-mode.ts` | Private mode as a person meets it (`docs/adr-private-mode-iroh.md` §19, §23): the four-point privacy disclosure ADR §3 requires *before* anything is published, the device rows — this browser's key store joined to the directory the fold read, including whether each device is addressed, retired, or has never said, which is what lets the space page keep the retired ones in a closed disclosure of their own — and the session-wired actions (accept a ticket, found a space, mint a ticket, rotate, retire). Founding runs `sidecar/private-cli.ts`'s sequence step for step and bookmarks last, so a create that could not seal the genesis announces nothing; minting reads the fold and writes nothing, and always names the founder's published key. The rules stay in `core`; what is here is the wiring, the explicit no-device-revocation compromise, and the one action that answers something going wrong — retiring a device withdraws its published address so no replica connects to it, and the surface says in as many words that it disconnects rather than un-counts (`docs/adr-private-mode-iroh.md` §29). | | `src/lib/identity.ts` | DID → PDS and DID → handle resolution, cached. A handle is shown only when it resolves back to the DID that claimed it. | +| `src/lib/handle-suggest.svelte.ts` | What a sign-in field offers while somebody types: this browser's remembered identities, then the handles a public appview's typeahead names for the prefix. One debounced, abortable ask per field, kept out of the sequence a stale answer could win. It is a dropdown and never a decision — signing in still resolves the handle through `auth.svelte.ts`, nothing here is fetched as a record, and a DID or a PDS address is never sent. `PUBLIC_RADIAL_HANDLE_TYPEAHEAD=off` turns the directory half off; both surfaces disclose it while it is on. | | `src/lib/units.ts` | Presentation over `timeline()`: a row's text, what quick find searches it by, its badges, its status disc, and the two cross-target capture relations no single target's index can see. Also the tip/open-request split — `requestState()` and the `isClaimed`/`isAssigned`/`isOpen`/`isAwaiting` predicates every list groups and counts by, because `UnitView.state` describes what LANDED and stays `judged` while a successor runs. | | `src/lib/requests.ts`, `verdicts.ts`, `admin.ts` | What each surface may offer and what it writes, as pure functions: the ⊕ menu, the review form and its findings, space administration. Tested without rendering anything. | | `src/lib/labels.ts` | The reading side of goal labels: the argv a label editor writes (always `--set`, always the whole set — the record has no add or remove), the space's label vocabulary with counts (which IS the registry: there is none on-protocol), and a chip's hue as a pure function of its text. The normalization *rule* is `@radial/core`'s, shared with the sidecar so a label typed here and one typed at a shell cannot differ. | @@ -107,6 +108,16 @@ served at — so `PUBLIC_RADIAL_ORIGIN=https://radial.example pnpm build` emits `client-metadata.json` into the build. Without it the bundle is complete and still signs in on a loopback host, where the client id encodes the metadata instead of pointing at it. + +The identity field on both sign-in surfaces suggests two things. The first is the identities this +browser has signed in as before, kept in `localStorage` beside the "Continue as" buttons — free, and +empty on a fresh profile. The second is what a public directory names for the prefix being typed +(`handle-suggest.svelte.ts`), which is what a person signing in for the first time actually needs. +That ask leaves the tab before anything is submitted, so both surfaces say where the suggestions come +from, a DID or a PDS address is never sent, and `PUBLIC_RADIAL_HANDLE_TYPEAHEAD` is the knob: an +appview of your own, or `off` to leave the field with the remembered identities alone. It is a +dropdown and nothing more — the handle a person settles on is resolved by the same code whether they +picked it or typed it in full, and `PUBLIC_RADIAL_HANDLE_DIRECTORY` is what governs *that*. ### Comments from outside the space diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -1186,6 +1186,10 @@ .signin-or { display: flex; align-items: center; gap: 8px; margin: 12px 0 9px; color: var(--ink-2); font-size: 10.5px; } .signin-or::before, .signin-or::after { content: ''; height: 1px; flex: 1; background: var(--line-soft); } .signin-or span { flex: none; } +/* Where the field's suggestions come from, said on both sign-in surfaces before anything is typed: + the remembered ones are this browser's, the rest are a public directory's, and asking one means + the prefix leaves the tab (`handle-suggest.svelte.ts`). */ +.signin-source { margin: 8px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--ink-2); } .acct-pop .acts { padding-top: 11px; } .acct-who { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; } .acct-who .nm { font-size: 13.5px; font-weight: 600; } diff --git a/packages/ui/test/sign-in-surfaces.test.mjs b/packages/ui/test/sign-in-surfaces.test.mjs --- a/packages/ui/test/sign-in-surfaces.test.mjs +++ b/packages/ui/test/sign-in-surfaces.test.mjs @@ -55,6 +55,19 @@ } }) + // Both surfaces ask the same module, so neither can end up with its own debounce, its own appview + // or its own idea of what is safe to send — and neither can offer a directory's suggestions + // without saying that the field's text is what buys them. + it('offer directory suggestions through the shared module, and disclose it', () => { + for (const { name, source } of surfaces) { + assert.match(source, /from '\$lib\/handle-suggest\.svelte\.js'/, name) + assert.match(source, /new HandleTypeahead\(\)/, name) + assert.match(source, /typeahead\.enabled/, name) + assert.match(source, /signin-source/, name) + assert.match(source, /\{typeahead\.host\}/, name) + } + }) + it('point every sign-in field at a list of remembered identities', () => { for (const { name, source } of surfaces) { const list = identityField(source).match(/\blist="([^"]+)"/) diff --git a/packages/ui/src/lib/handle-suggest.svelte.ts b/packages/ui/src/lib/handle-suggest.svelte.ts new file mode 100644 --- /dev/null +++ b/packages/ui/src/lib/handle-suggest.svelte.ts @@ -0,0 +1,290 @@ +// Handles somebody could sign in as, offered while they type. +// +// The sign-in field already suggests this browser's remembered identities (`auth.svelte.ts`), which +// are exact, free, and known here — and empty on a fresh profile, or for a second identity this +// browser has never held. That is the case this module answers: the prefix being typed is asked of +// Bluesky's public appview, whose `app.bsky.actor.searchActorsTypeahead` is the same call Bluesky's +// own login and search fields make, and the handles it names join the datalist behind the remembered +// ones. +// +// A directory query in an app with no central server deserves its boundary stated, so: three things +// this is not. +// +// - **Not identity resolution.** Signing in resolves the typed handle through `auth.svelte.ts`'s +// own resolver — `com.atproto.identity.resolveHandle` on the configured directory, falling back +// to the handle domain's `.well-known/atproto-did`. A handle this appview never heard of signs in +// exactly as before, and a handle it *offers* is resolved by that same code as if it had been +// typed in full. Nothing here shortens the path from a suggestion to a session. +// - **Not anything the fold reads.** The rule `guests.ts` and `issues.ts` follow holds here with +// one fewer moving part: this returns strings for a dropdown. No record is fetched, none is +// validated, and nothing reaches a `RecordStore`. +// - **Not silent.** Keystrokes leave the tab before anything is submitted, which is a different +// bargain from the one a sign-in makes, so both surfaces say where the suggestions come from and +// `PUBLIC_RADIAL_HANDLE_TYPEAHEAD=off` turns the whole thing off at build time — an operator +// running Radial for a community that has no business talking to that appview should not have to +// patch a component to say so. +// +// What is asked is also less than what is typed. A DID, a PDS address, and anything with a space or +// a slash in it are never sent: none of them is a handle, so the appview could only answer nothing, +// and not asking is the difference between "we send a handle prefix" and "we send the field". + +export type Fetcher = (input: string, init?: RequestInit) => Promise + +/** + * Bluesky's public read-only appview. A constant rather than a literal at the call site for the + * reason `TANGLED_APPVIEW` is one: it is somebody else's public instance, and an operator who needs + * to own it should have one place to point this at. + */ +export const BSKY_APPVIEW = 'https://public.api.bsky.app' + +/** Per ask. A dropdown is read at a glance; the remembered identities share the same list. */ +const TYPEAHEAD_LIMIT = 8 + +/** What a merged list is capped at, remembered identities included. */ +export const MAX_SUGGESTIONS = 10 + +/** Long enough that a fast typist makes one request instead of six, short enough to feel immediate. */ +const DEBOUNCE_MS = 180 + +/** + * Where suggestions are asked, or `''` for nowhere. + * + * Read from the build's env like `PUBLIC_RADIAL_ORIGIN` and `PUBLIC_RADIAL_HANDLE_DIRECTORY`, and + * read through a function rather than at module load so a test can reason about it without a build. + * An operator points it at their own appview, or sets `off` (or an empty value) to leave the field + * with the remembered identities alone. + */ +export function typeaheadOrigin(): string { + const configured = import.meta.env.PUBLIC_RADIAL_HANDLE_TYPEAHEAD as string | undefined + if (configured === undefined) return BSKY_APPVIEW + const trimmed = configured.trim() + if (trimmed === '' || trimmed === 'off') return '' + return trimmed.replace(/\/+$/, '') +} + +/** Whether this build asks anybody at all — what the surfaces' disclosure line is drawn from. */ +export const typeaheadEnabled = (): boolean => typeaheadOrigin() !== '' + +/** + * The host a person is told about. Named rather than described ("Bluesky's directory") so the + * sentence stays true for an operator who pointed `PUBLIC_RADIAL_HANDLE_TYPEAHEAD` at their own. + */ +export function typeaheadHost(origin: string = typeaheadOrigin()): string { + if (!origin) return '' + try { + return new URL(origin).host + } catch { + return origin + } +} + +/** + * The handle prefix in what has been typed, or nothing if there is none worth asking about. + * + * A leading `@` is dropped because people type it and no handle contains one. Everything else this + * refuses is a thing the appview has no answer for: a DID resolves without a directory, a PDS + * address is a server and not a person, and whitespace or a path means whatever is in the field is + * not a handle yet. One character is refused as well — the answer would be the appview's most + * popular accounts rather than anything about this person. + */ +export function handleQuery(input: string): string | undefined { + const typed = input.trim().replace(/^@+/, '').toLowerCase() + if (typed.length < 2) return undefined + if (typed.startsWith('did:')) return undefined + if (typed.includes('://') || typed.includes('/') || /\s/.test(typed)) return undefined + return typed +} + +const object = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** + * One `app.bsky.actor.searchActorsTypeahead` page, as handles. + * + * Malformed entries are dropped rather than thrown on — the appview's actor shape carries far more + * than is read here, and one unrecognisable row must not lose the rest. A failed REQUEST throws, and + * the caller turns that into an empty dropdown: "the directory did not answer" is not worth + * interrupting somebody typing their own handle. + * + * Results are kept to handles CONTAINING the query, because a `datalist` is filtered by the browser + * against the field's value: the appview also matches display names, and an option whose handle does + * not contain what was typed is one no browser would show. Filtering here keeps the list this module + * reports and the list a person sees the same list. + */ +export async function searchHandles(input: { + query: string + origin?: string + limit?: number + fetcher?: Fetcher + signal?: AbortSignal +}): Promise { + const origin = input.origin ?? BSKY_APPVIEW + if (!origin) return [] + const query = new URLSearchParams({ + q: input.query, + limit: String(input.limit ?? TYPEAHEAD_LIMIT), + }) + const url = `${origin}/xrpc/app.bsky.actor.searchActorsTypeahead?${query}` + const response = await (input.fetcher ?? fetch)( + url, + input.signal ? { signal: input.signal } : undefined, + ) + if (!response.ok) throw new Error(`the handle directory answered ${response.status}`) + const payload: unknown = await response.json() + const actors = object(payload) && Array.isArray(payload.actors) ? payload.actors : [] + const handles: string[] = [] + for (const actor of actors) { + if (!object(actor)) continue + const handle = typeof actor.handle === 'string' ? actor.handle.trim().toLowerCase() : '' + // `handle.invalid` is what an appview shows for an account whose handle stopped resolving. It is + // not something anybody can sign in as. + if (!handle || handle === 'handle.invalid') continue + if (!handle.includes(input.query)) continue + if (handles.includes(handle)) continue + handles.push(handle) + } + return handles +} + +/** + * What the field offers: the identities this browser has actually signed in as, then whatever the + * directory suggested, deduplicated case-insensitively and capped. + * + * Remembered first because they are the ones a second press of Continue would use, and because a + * handle this browser has held is a stronger guess than a stranger's with a similar name. + */ +export function mergeSuggestions(remembered: string[], suggested: string[]): string[] { + const merged: string[] = [] + const seen = new Set() + for (const value of [...remembered, ...suggested]) { + const label = value.trim() + const key = label.toLowerCase() + if (!label || seen.has(key)) continue + seen.add(key) + merged.push(label) + if (merged.length === MAX_SUGGESTIONS) break + } + return merged +} + +export interface TypeaheadOptions { + fetcher?: Fetcher + /** Overridden by tests; the app takes `typeaheadOrigin()`, including its `off`. */ + origin?: string + delay?: number + limit?: number +} + +/** + * A sign-in field's directory suggestions: what has been typed, debounced, asked once, and safe to + * abandon. + * + * One instance per field rather than a shared store, because the picker carries the account + * popover's chip and both fields can be on screen holding different text — a shared list would put + * one field's answer in the other's dropdown, exactly as a shared `datalist` id would. + * + * A request in flight is aborted when the typing moves on, and a response that arrives after a newer + * ask started is dropped on sequence rather than trusted to arrive in order: the network decides + * which answer comes back first, and the field decides which question is current. + */ +export class HandleTypeahead { + readonly #fetcher: Fetcher | undefined + readonly #origin: string + readonly #delay: number + readonly #limit: number | undefined + #timer: ReturnType | undefined + #settle: (() => void) | undefined + #controller: AbortController | undefined + #asked = '' + #seq = 0 + #pending: Promise = Promise.resolve() + + /** The handles the directory offered for what is in the field now. */ + handles = $state([]) + + constructor(options: TypeaheadOptions = {}) { + this.#fetcher = options.fetcher + this.#origin = options.origin ?? typeaheadOrigin() + this.#delay = options.delay ?? DEBOUNCE_MS + this.#limit = options.limit + } + + /** Whether this instance asks anybody — false when the build turned typeahead off. */ + get enabled(): boolean { + return this.#origin !== '' + } + + /** Who it asks, for the sentence the surface says before anybody types. */ + get host(): string { + return typeaheadHost(this.#origin) + } + + /** + * Take what is in the field. Resolves once the ask it caused has settled, which is what lets a + * test await the answer instead of racing it; callers in components ignore it. + */ + suggest(input: string): Promise { + if (!this.enabled) return this.#pending + const query = handleQuery(input) + if (!query) { + this.#cancel() + this.#asked = '' + this.#seq += 1 + this.handles = [] + return (this.#pending = Promise.resolve()) + } + if (query === this.#asked) return this.#pending + this.#cancel() + this.#asked = query + const seq = ++this.#seq + return (this.#pending = new Promise((resolve) => { + this.#settle = resolve + this.#timer = setTimeout(() => { + this.#timer = undefined + this.#settle = undefined + void this.#ask(query, seq).then(resolve, resolve) + }, this.#delay) + })) + } + + /** Drop the suggestions and whatever was on its way to producing more. */ + dispose(): void { + this.#cancel() + this.#asked = '' + this.#seq += 1 + this.handles = [] + } + + async #ask(query: string, seq: number): Promise { + const controller = new AbortController() + this.#controller = controller + try { + const handles = await searchHandles({ + query, + origin: this.#origin, + ...(this.#fetcher ? { fetcher: this.#fetcher } : {}), + ...(this.#limit === undefined ? {} : { limit: this.#limit }), + signal: controller.signal, + }) + if (seq === this.#seq) this.handles = handles + } catch { + // A directory that did not answer has nothing to offer, and the field it is beside works + // without it: the remembered identities stay, and typing a handle in full still signs in. + if (seq === this.#seq) this.handles = [] + } finally { + if (this.#controller === controller) this.#controller = undefined + } + } + + // An ask that never gets made still has to end: the promise `suggest` handed back is what a test + // awaits, and a cancelled debounce that left one pending would hang it rather than fail it. + #cancel(): void { + if (this.#timer !== undefined) clearTimeout(this.#timer) + this.#timer = undefined + const settle = this.#settle + this.#settle = undefined + settle?.() + this.#controller?.abort() + this.#controller = undefined + } +} diff --git a/packages/ui/src/lib/handle-suggest.test.ts b/packages/ui/src/lib/handle-suggest.test.ts new file mode 100644 --- /dev/null +++ b/packages/ui/src/lib/handle-suggest.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import { + BSKY_APPVIEW, + HandleTypeahead, + MAX_SUGGESTIONS, + handleQuery, + mergeSuggestions, + searchHandles, + typeaheadEnabled, + typeaheadHost, + typeaheadOrigin, +} from './handle-suggest.svelte.js' + +/** An appview that answers with the actors it was handed, and records what it was asked. */ +function appview(actors: unknown[], status = 200) { + const asked: string[] = [] + const fetcher = vi.fn(async (url: string, init?: RequestInit) => { + asked.push(url) + if (init?.signal?.aborted) throw new Error('aborted') + return new Response(JSON.stringify({ actors }), { status }) + }) + return { fetcher, asked } +} + +const actor = (handle: string, displayName = '') => ({ + did: `did:plc:${handle.replace(/\W/g, '')}`, + handle, + displayName, +}) + +describe('what is worth asking about', () => { + it('takes a handle prefix, however it was typed', () => { + expect(handleQuery('al')).toBe('al') + expect(handleQuery(' Alice.Test ')).toBe('alice.test') + // People type the sigil; no handle contains one. + expect(handleQuery('@alice.test')).toBe('alice.test') + }) + + it('never sends what the directory could not answer', () => { + expect(handleQuery('')).toBeUndefined() + expect(handleQuery('a')).toBeUndefined() + expect(handleQuery('did:plc:abc123')).toBeUndefined() + expect(handleQuery('https://pds.example')).toBeUndefined() + expect(handleQuery('pds.example/xrpc')).toBeUndefined() + expect(handleQuery('alice test')).toBeUndefined() + }) +}) + +describe('asking the directory', () => { + it('asks the public appview for the prefix, bounded', async () => { + const { fetcher, asked } = appview([actor('alice.test'), actor('alicia.test')]) + expect(await searchHandles({ query: 'ali', fetcher })).toEqual(['alice.test', 'alicia.test']) + expect(asked[0]).toContain(`${BSKY_APPVIEW}/xrpc/app.bsky.actor.searchActorsTypeahead?`) + expect(asked[0]).toContain('q=ali') + expect(asked[0]).toMatch(/limit=\d+/) + }) + + it('drops what nobody could sign in as, and what no browser would show', async () => { + const { fetcher } = appview([ + actor('handle.invalid', 'Alison'), + // The appview matches display names too; a `datalist` is filtered by the browser against the + // field's value, so an option whose handle does not contain the query is one nobody sees. + actor('bob.test', 'Alice Bobson'), + { did: 'did:plc:shapeless' }, + actor('ALICE.test'), + actor('alice.test'), + ]) + expect(await searchHandles({ query: 'alice', fetcher })).toEqual(['alice.test']) + }) + + it('says so when the directory refuses', async () => { + const { fetcher } = appview([], 503) + await expect(searchHandles({ query: 'ali', fetcher })).rejects.toThrow(/503/) + }) + + it('asks nobody when there is nowhere to ask', async () => { + const { fetcher } = appview([actor('alice.test')]) + expect(await searchHandles({ query: 'ali', origin: '', fetcher })).toEqual([]) + expect(fetcher).not.toHaveBeenCalled() + }) +}) + +describe('what the field offers', () => { + it('puts the identities this browser has held first', () => { + expect(mergeSuggestions(['alice.test'], ['bob.test'])).toEqual(['alice.test', 'bob.test']) + }) + + it('says each identity once, however it was capitalised', () => { + expect(mergeSuggestions(['Alice.test'], ['alice.test', 'bob.test'])).toEqual([ + 'Alice.test', + 'bob.test', + ]) + }) + + it('is a list somebody can read at a glance', () => { + const suggested = Array.from({ length: 40 }, (_, index) => `person${index}.test`) + expect(mergeSuggestions(['alice.test'], suggested)).toHaveLength(MAX_SUGGESTIONS) + expect(mergeSuggestions([], []).length).toBe(0) + }) +}) + +describe('a sign-in field asking', () => { + const typeahead = (fetcher: unknown) => + new HandleTypeahead({ fetcher: fetcher as never, delay: 0, origin: BSKY_APPVIEW }) + + it('offers the handles the directory named', async () => { + const { fetcher } = appview([actor('alice.test')]) + const field = typeahead(fetcher) + await field.suggest('ali') + expect(field.handles).toEqual(['alice.test']) + }) + + it('asks once for what a person typed one letter at a time', async () => { + const { fetcher } = appview([actor('alice.test')]) + const field = new HandleTypeahead({ fetcher, delay: 5, origin: BSKY_APPVIEW }) + void field.suggest('a') + void field.suggest('al') + const settled = field.suggest('ali') + await settled + expect(fetcher).toHaveBeenCalledTimes(1) + expect(field.handles).toEqual(['alice.test']) + }) + + it('keeps the answer to the question the field is asking now', async () => { + // The slow answer belongs to the earlier prefix. It arrives last, and must not land. + const answers: Record = { + ali: { delay: 20, handles: ['alice.test'] }, + alic: { delay: 0, handles: ['alicia.test'] }, + } + const fetcher = vi.fn(async (url: string) => { + const query = new URL(url).searchParams.get('q') ?? '' + const answer = answers[query] ?? { delay: 0, handles: [] } + await new Promise((resolve) => setTimeout(resolve, answer.delay)) + return new Response(JSON.stringify({ actors: answer.handles.map((h) => actor(h)) }), { + status: 200, + }) + }) + const field = typeahead(fetcher) + const first = field.suggest('ali') + // Long enough that the ask for `ali` is on the wire — a debounce cancelled before it fires would + // have no stale answer to drop, and would leave this asserting nothing. + await new Promise((resolve) => setTimeout(resolve, 5)) + const second = field.suggest('alic') + await Promise.all([first, second]) + expect(fetcher).toHaveBeenCalledTimes(2) + expect(field.handles).toEqual(['alicia.test']) + }) + + it('forgets its suggestions when the field stops holding a handle', async () => { + const { fetcher } = appview([actor('alice.test')]) + const field = typeahead(fetcher) + await field.suggest('ali') + await field.suggest('did:plc:alice') + expect(field.handles).toEqual([]) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('leaves the field alone when the directory does not answer', async () => { + const { fetcher } = appview([], 500) + const field = typeahead(fetcher) + await field.suggest('ali') + expect(field.handles).toEqual([]) + }) + + it('asks nobody when the build turned typeahead off', async () => { + const { fetcher } = appview([actor('alice.test')]) + const field = new HandleTypeahead({ fetcher, delay: 0, origin: '' }) + expect(field.enabled).toBe(false) + await field.suggest('ali') + expect(fetcher).not.toHaveBeenCalled() + expect(field.handles).toEqual([]) + }) + + it('drops a pending ask with the surface that made it', async () => { + const { fetcher } = appview([actor('alice.test')]) + const field = new HandleTypeahead({ fetcher, delay: 50, origin: BSKY_APPVIEW }) + void field.suggest('ali') + field.dispose() + await new Promise((resolve) => setTimeout(resolve, 80)) + expect(fetcher).not.toHaveBeenCalled() + expect(field.handles).toEqual([]) + }) +}) + +describe('where a build asks', () => { + it('defaults to the public appview', () => { + expect(typeaheadOrigin()).toBe(BSKY_APPVIEW) + expect(typeaheadEnabled()).toBe(true) + }) + + it('names the host a person is told about', () => { + expect(typeaheadHost()).toBe('public.api.bsky.app') + expect(typeaheadHost('https://appview.example:8443')).toBe('appview.example:8443') + expect(typeaheadHost('')).toBe('') + }) + + it('takes an operator’s own, and takes no for an answer', () => { + vi.stubEnv('PUBLIC_RADIAL_HANDLE_TYPEAHEAD', 'https://appview.example/') + expect(typeaheadOrigin()).toBe('https://appview.example') + vi.stubEnv('PUBLIC_RADIAL_HANDLE_TYPEAHEAD', 'off') + expect(typeaheadOrigin()).toBe('') + expect(typeaheadEnabled()).toBe(false) + vi.stubEnv('PUBLIC_RADIAL_HANDLE_TYPEAHEAD', '') + expect(typeaheadEnabled()).toBe(false) + vi.unstubAllEnvs() + }) +}) diff --git a/packages/ui/src/lib/components/Account.svelte b/packages/ui/src/lib/components/Account.svelte --- a/packages/ui/src/lib/components/Account.svelte +++ b/packages/ui/src/lib/components/Account.svelte @@ -2,6 +2,7 @@ import { account, rememberedIdentities, signIn, signOut } from '$lib/auth.svelte.js' import { unknownActor } from '$lib/directory.js' import { focusStranded, keepFocus } from '$lib/focus.js' + import { HandleTypeahead, mergeSuggestions } from '$lib/handle-suggest.svelte.js' import type { Space } from '$lib/space.js' import { ui } from '$lib/ui.svelte.js' import Disc from './Disc.svelte' @@ -22,6 +23,22 @@ let identifier = $state('') let field = $state(null) let chip = $state(null) + + // The suggestions this field offers: the identities this browser has held, then whatever the + // public directory names for the prefix being typed. One instance per surface, because the picker + // carries this popover's chip and the two fields can hold different text at once. + const typeahead = new HandleTypeahead() + const suggestions = $derived( + mergeSuggestions( + rememberedIdentities.map((identity) => identity.handle ?? identity.did), + typeahead.handles, + ), + ) + $effect(() => { + void typeahead.suggest(identifier) + }) + // A popover that goes away takes its pending ask with it. + $effect(() => () => typeahead.dispose()) // A member's own resolved handle wins; before the directory has one, the handle the session was // established under stands in. Either way the disc's colour comes from the DID, so it never moves. @@ -147,11 +164,13 @@ field could offer — a browser fills them from a value it recorded for this origin, and this origin records neither kind: the password is typed at the PDS, so no manager ever stores a login here, and `submit` cancels the submission, so form history never captures the handle - either. The suggestions therefore come from the one place that does hold identities Radial - knows: `rememberedIdentities`, the same list the "Continue as" buttons above are built from. - A `datalist` turns it into type-ahead that filters as you type, in every browser, without - depending on a submission the app deliberately prevents. The id is scoped to this component - because the picker carries the same field and both can be on screen at once. + either. The suggestions therefore come from the two places that do hold identities: this + browser's `rememberedIdentities`, the same list the "Continue as" buttons above are built + from, and — for the fresh profile where that list is empty — the public directory + `handle-suggest.svelte.ts` asks as the prefix is typed. A `datalist` turns both into + type-ahead that filters as you type, in every browser, without depending on a submission the + app deliberately prevents. The id is scoped to this component because the picker carries the + same field and both can be on screen at once. -->
+ {#if typeahead.enabled} + + {/if}