import { useEffect, useRef, useState } from "react"; import { InfoIcon, LoaderIcon, SearchIcon } from "@chopin/icons"; import { ReferenceErrorIcon } from "./reference-status-icon"; import * as Api from "../api"; import { MAX_REFERENCES, referenceTriggerKey } from "./references"; import type { ReferenceTarget, ReferenceTrigger } from "./references"; const SEARCH_DELAY = 160; export type ReferenceSearchApi = Pick; export type ReferencePickerRequest = { id: number; key: string; controller: AbortController; }; export type ReferencePickerState = | { status: "idle" | "loading" | "limit"; options: ReferenceTarget[] } | { status: "ready"; options: ReferenceTarget[]; truncated?: boolean } | { status: "error"; options: ReferenceTarget[]; error: unknown }; export type ReferenceSearchResult = { options: ReferenceTarget[]; truncated: boolean }; type LoadedPickerState = ReferencePickerState & { key: string }; export function currentReferencePickerRequest( current: ReferencePickerRequest | undefined, candidate: ReferencePickerRequest, ): boolean { return current === candidate && !candidate.controller.signal.aborted; } export function referencePickerRequestKey( trigger: ReferenceTrigger, repository: Pick, room: string, ): string { return JSON.stringify([ repository.id, repository.owner, repository.name, room, referenceTriggerKey(trigger), ]); } export type ReferencePickerKeyAction = "dismiss" | "next" | "previous" | "select"; export function referencePickerKeyAction( event: { key: string; keyCode?: number; isComposing?: boolean; shiftKey?: boolean }, selectable: boolean, ): ReferencePickerKeyAction | undefined { if (event.isComposing || event.keyCode === 229) return undefined; if (event.key === "Escape") return "dismiss"; if (!selectable) return undefined; if (event.key === "ArrowDown") return "next"; if (event.key === "ArrowUp") return "previous"; if (event.key === "Enter" && !event.shiftKey) return "select"; return undefined; } export async function searchReferenceTargets( trigger: ReferenceTrigger, repository: Pick, room: string, signal: AbortSignal, api: ReferenceSearchApi = Api, ): Promise { let channels = new Map(); let titles = new Map(); let cursor: string | undefined; let pages = 0; let omitted = false; do { let page = await api.channels( repository.owner, repository.name, { cursor, includeArchived: false, query: trigger.query || undefined, signal, }, ); for (let channel of page.channels) titles.set(channel.id, channel.title); for (let [index, channel] of page.channels.entries()) { if (channel.id !== room) channels.set(channel.id, channel); if (channels.size >= MAX_REFERENCES) { omitted = index < page.channels.length - 1; break; } } cursor = page.nextCursor; pages++; } while (channels.size < MAX_REFERENCES && cursor && pages < 5); return { options: [...channels.values()].slice(0, MAX_REFERENCES).map(channel => ({ kind: "document" as const, channelId: channel.id, title: channel.title, slug: channel.slug, ...(channel.parentChannelId ? { child: true, ...(titles.get(channel.parentChannelId) ? { parentTitle: titles.get(channel.parentChannelId) } : {}), } : {}), ...(channel.description ? { description: channel.description } : {}), })), truncated: omitted || !!cursor, }; } export function useReferencePicker( trigger: ReferenceTrigger | undefined, repository: Pick, room: string, ): ReferencePickerState & { active: number; setActive: (active: number | ((current: number) => number)) => void; } { let sequence = useRef(0); let request = useRef(undefined); let [active, setActive] = useState(0); let [loaded, setLoaded] = useState({ key: "", status: "idle", options: [], }); let key = trigger ? referencePickerRequestKey(trigger, repository, room) : ""; let triggerKind = trigger?.kind; let triggerMarker = trigger?.marker; let triggerQuery = trigger?.query; let triggerStart = trigger?.start; let triggerEnd = trigger?.end; useEffect(() => { if ( triggerKind === undefined || triggerMarker === undefined || triggerQuery === undefined || triggerStart === undefined || triggerEnd === undefined ) return; let selected: ReferenceTrigger = { kind: triggerKind, marker: triggerMarker, query: triggerQuery, start: triggerStart, end: triggerEnd, }; let candidate = { id: ++sequence.current, key, controller: new AbortController(), }; request.current = candidate; setActive(0); setLoaded({ key, status: "loading", options: [] }); let timer = window.setTimeout(() => { void searchReferenceTargets( selected, repository, room, candidate.controller.signal, ).then(result => { if (!currentReferencePickerRequest(request.current, candidate)) return; setLoaded({ key, status: "ready", ...result }); }, error => { if (!currentReferencePickerRequest(request.current, candidate)) return; setLoaded({ key, status: "error", options: [], error }); }); }, SEARCH_DELAY); return () => { window.clearTimeout(timer); candidate.controller.abort(); if (request.current === candidate) request.current = undefined; }; }, [ key, repository.id, repository.name, repository.owner, room, triggerEnd, triggerKind, triggerMarker, triggerQuery, triggerStart, ]); if (!trigger) return { status: "idle", options: [], active, setActive }; if (request.current?.key !== key || loaded.key !== key) { return { status: "loading", options: [], active: 0, setActive }; } return { ...loaded, active, setActive }; } export function referenceOptionId(listId: string, index: number): string { return `${listId}-option-${index}`; } function failureMessage(error: unknown): string { return error instanceof Error ? error.message : "Could not load references."; } export function ReferencePicker( { active, id, onActive, onSelect, state, }: { active: number; id: string; onActive: (index: number) => void; onSelect: (target: ReferenceTarget) => void; state: ReferencePickerState; }, ) { let [animate] = useState(() => typeof document !== "undefined" && document.documentElement.dataset.motionInput === "pointer" ); let label = "Document references"; let empty = state.status === "ready" && state.truncated ? "No matches in the available documents." : "No matching documents."; useEffect(() => { if (state.options.length === 0) return; let frame = requestAnimationFrame(() => { document.getElementById(referenceOptionId(id, active))?.scrollIntoView({ block: "nearest" }); }); return () => cancelAnimationFrame(frame); }, [active, id, state.options.length]); return (
{state.status === "loading" && (

Loading documents...

)} {state.status === "limit" && (

A message can include up to 10 references.

)} {state.status === "error" && (

{failureMessage(state.error)}

)} {state.status === "ready" && state.options.length === 0 && (

{empty}

)} {state.status === "ready" && state.truncated && (

Some documents are not shown.

)} {state.options.map((option, index) => { let generatedDescription = option.description ? `${referenceOptionId(id, index)}-description` : undefined; // A child document's slug is an internal id; its title already names it. let showSlug = !option.child && !!option.slug && option.slug !== option.title; let slugDescription = showSlug ? `${referenceOptionId(id, index)}-slug` : undefined; let describedBy = [generatedDescription, slugDescription].filter(Boolean).join(" ") || undefined; return ( ); })}
); }