Files
DiscordClone/packages/shared/src/components/channel/MentionAutocomplete.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

258 lines
6.9 KiB
TypeScript

/**
* 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<MentionAutocompleteHandle, Props>(
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<HTMLDivElement>(null);
const membersRaw = useQuery(
api.members.getChannelMembers,
channelId ? { channelId: channelId as any } : 'skip',
) as ConvexMember[] | undefined;
const items = useMemo<MentionItem[]>(() => {
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<HTMLElement>(`[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(
<div
ref={containerRef}
className={styles.container}
style={{
position: 'fixed',
left: pos.left,
bottom: pos.bottom,
width: pos.width,
zIndex: 15000,
}}
// Prevent the contenteditable from losing focus when users
// click into the popup.
onMouseDown={(e) => e.preventDefault()}
>
<div className={styles.header}>MEMBERS</div>
<div className={styles.scroller}>
{items.map((item, index) => {
const isActive = index === selected;
return (
<button
key={item.kind === 'everyone' ? 'everyone' : item.userId}
type="button"
data-index={index}
className={`${styles.row} ${isActive ? styles.rowActive : ''}`}
onMouseEnter={() => setSelected(index)}
onClick={() => onSelect(item)}
>
<div className={styles.icon}>
{item.kind === 'everyone' ? (
<div className={styles.everyoneIcon}>
<Megaphone size={16} weight="fill" />
</div>
) : (
<Avatar
src={item.avatar}
fallback={item.displayName}
size={24}
/>
)}
</div>
<div className={styles.nameWrapper}>
<div className={styles.name}>
{item.kind === 'everyone' ? '@everyone' : item.displayName}
</div>
</div>
<div className={styles.description}>
{item.kind === 'everyone'
? 'Notify everyone in this channel'
: item.username}
</div>
</button>
);
})}
</div>
</div>,
document.body,
);
},
);