import { type RefObject, useCallback, useEffect, useRef, useState, } from "react"; import { API_URL } from "../config/api.ts"; export interface UserResult { id: string; username: string; avatarMime: string | null; } interface MentionState { open: boolean; query: string; start: number; results: UserResult[]; selectedIndex: number; } const CLOSED: MentionState = { open: false, query: "", start: 0, results: [], selectedIndex: 0, }; function getMentionQuery( value: string, pos: number, ): { query: string; start: number } | null { const textBefore = value.slice(0, pos); // Match @word at end of text before cursor, not preceded by [ ( or word char const match = textBefore.match(/(? void, textareaRef: RefObject, ) { const [state, setState] = useState(CLOSED); const debounceRef = useRef | null>(null); const cursorRef = useRef(0); const handleMentionChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value; const pos = e.target.selectionStart ?? newValue.length; cursorRef.current = pos; onChange(newValue); const mention = getMentionQuery(newValue, pos); if (!mention) { setState(CLOSED); if (debounceRef.current) clearTimeout(debounceRef.current); return; } setState((s) => ({ ...s, open: false, query: mention.query, start: mention.start, selectedIndex: 0, })); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(async () => { try { const res = await fetch( `${API_URL}/api/users/search?q=${ encodeURIComponent(mention.query) }`, ); const body = await res.json(); if (body.success && body.data.length > 0) { setState((s) => s.query === mention.query ? { ...s, open: true, results: body.data, selectedIndex: 0 } : s ); } else { setState(CLOSED); } } catch { setState(CLOSED); } }, 150); }, [onChange], ); const doSelect = useCallback( (username: string, start: number, cursorPos: number) => { const before = value.slice(0, start); const after = value.slice(cursorPos); onChange(`${before}@${username} ${after}`); setState(CLOSED); if (debounceRef.current) clearTimeout(debounceRef.current); setTimeout(() => { const el = textareaRef.current; if (el) { const newPos = start + username.length + 2; // @ + username + space el.focus(); el.setSelectionRange(newPos, newPos); } }, 0); }, [value, onChange, textareaRef], ); const handleMentionKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!state.open || state.results.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setState((s) => ({ ...s, selectedIndex: Math.min(s.selectedIndex + 1, s.results.length - 1), })); } else if (e.key === "ArrowUp") { e.preventDefault(); setState((s) => ({ ...s, selectedIndex: Math.max(s.selectedIndex - 1, 0), })); } else if ( (e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey ) { e.preventDefault(); const user = state.results[state.selectedIndex]; if (user) doSelect(user.username, state.start, cursorRef.current); } else if (e.key === "Escape") { e.preventDefault(); setState(CLOSED); } }, [state, doSelect], ); const handleMentionSelect = useCallback( (username: string) => { doSelect(username, state.start, cursorRef.current); }, [doSelect, state.start], ); useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); return { mentionOpen: state.open, mentionResults: state.results, mentionSelectedIndex: state.selectedIndex, handleMentionChange, handleMentionKeyDown, handleMentionSelect, }; }