/** * Mention autocomplete popup — opens above the chat input when the user * types `@`. Renders the server members for the current channel, with * arrow-key navigation and Enter/click to insert. */ import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { createPortal } from 'react-dom'; import { useQuery } from 'convex/react'; import { Avatar } from '@discord-clone/ui'; import { Megaphone } from '@phosphor-icons/react'; import { api } from '../../../../../convex/_generated/api'; import styles from './MentionAutocomplete.module.css'; export type MentionItem = | { kind: 'user'; userId: string; displayName: string; username: string; avatar?: string; } | { kind: 'everyone'; }; export interface MentionAutocompleteHandle { /** Move the keyboard selection. Returns true if the popup consumed the key. */ moveSelection: (delta: number) => boolean; /** Commit the currently highlighted item. Returns true if something was selected. */ commit: () => boolean; } interface Props { channelId: string; query: string; anchorEl: HTMLElement | null; onSelect: (item: MentionItem) => void; onClose: () => void; } const MAX_RESULTS = 10; /** * Rank a candidate against the query. Lower score = better. * -2: startsWith match (best) * -1: word-boundary match * 0: substring match anywhere * null: no match */ function scoreMatch(candidate: string, query: string): number | null { if (!query) return 0; const c = candidate.toLowerCase(); const q = query.toLowerCase(); if (c.startsWith(q)) return -2; const words = c.split(/[\s_\-.:/]+/); for (const w of words) { if (w !== c && w.startsWith(q)) return -1; } return c.includes(q) ? 0 : null; } interface ConvexMember { id: string; username: string; displayName: string | null; avatarUrl: string | null; } export const MentionAutocomplete = forwardRef( function MentionAutocomplete({ channelId, query, anchorEl, onSelect, onClose }, ref) { const [selected, setSelected] = useState(0); const [pos, setPos] = useState<{ left: number; bottom: number; width: number } | null>( null, ); const containerRef = useRef(null); const membersRaw = useQuery( api.members.getChannelMembers, channelId ? { channelId: channelId as any } : 'skip', ) as ConvexMember[] | undefined; const items = useMemo(() => { const result: MentionItem[] = []; if (scoreMatch('everyone', query) !== null) { result.push({ kind: 'everyone' }); } if (membersRaw) { type Scored = { item: MentionItem; score: number; name: string }; const scored: Scored[] = []; for (const m of membersRaw) { const name = m.displayName || m.username; const s1 = scoreMatch(name, query); const s2 = scoreMatch(m.username, query); const best = s1 === null && s2 === null ? null : Math.min(s1 ?? Infinity, s2 ?? Infinity); if (best === null) continue; scored.push({ item: { kind: 'user', userId: m.id, displayName: name, username: m.username, avatar: m.avatarUrl ?? undefined, }, score: best, name, }); } scored.sort((a, b) => { if (a.score !== b.score) return a.score - b.score; return a.name.localeCompare(b.name); }); result.push(...scored.slice(0, MAX_RESULTS).map((s) => s.item)); } return result; }, [membersRaw, query]); // Clamp selection when the list changes. useEffect(() => { setSelected((prev) => { if (items.length === 0) return 0; if (prev >= items.length) return items.length - 1; return prev; }); }, [items]); // Close if the filter yielded nothing — but only after the query // has had a chance to resolve. While the query is still loading // (membersRaw === undefined) we keep the popup open so the user // doesn't see it flicker. useEffect(() => { if (membersRaw !== undefined && items.length === 0) { onClose(); } }, [membersRaw, items.length, onClose]); // Position above the anchor (the textarea). useLayoutEffect(() => { if (!anchorEl) return; const update = () => { const rect = anchorEl.getBoundingClientRect(); setPos({ left: rect.left, bottom: window.innerHeight - rect.top + 8, width: Math.max(280, Math.min(rect.width, 520)), }); }; update(); window.addEventListener('resize', update); window.addEventListener('scroll', update, true); return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true); }; }, [anchorEl]); // Keep the highlighted row in view. useEffect(() => { const container = containerRef.current; if (!container) return; const el = container.querySelector(`[data-index="${selected}"]`); el?.scrollIntoView({ block: 'nearest' }); }, [selected]); useImperativeHandle( ref, () => ({ moveSelection: (delta) => { if (items.length === 0) return false; setSelected((prev) => { const next = (prev + delta + items.length) % items.length; return next; }); return true; }, commit: () => { const item = items[selected]; if (!item) return false; onSelect(item); return true; }, }), [items, selected, onSelect], ); if (!pos || items.length === 0) return null; return createPortal(
e.preventDefault()} >
MEMBERS
{items.map((item, index) => { const isActive = index === selected; return ( ); })}
, document.body, ); }, );