124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
import type { ConversationSummary } from '@chat-app/shared/chat';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
import { useNickname } from '../lib/friendNicknames';
|
|
import { Avatar } from './Avatar';
|
|
|
|
type Member = ConversationSummary['members'][number];
|
|
|
|
interface Props {
|
|
members: ConversationSummary['members'];
|
|
query: string;
|
|
excludeUserId: string | undefined;
|
|
onSelect: (username: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// Dropdown shown above the composer when the user has typed `@` followed
|
|
// by the start of a member name. Keyboard-first — arrow keys move through,
|
|
// enter/tab commits, escape cancels.
|
|
export function MentionAutocomplete({ members, query, excludeUserId, onSelect, onClose }: Props) {
|
|
const q = query.toLowerCase();
|
|
const matches = members
|
|
.filter((m) => m.userId !== excludeUserId)
|
|
.filter((m) => {
|
|
if (!q) return true;
|
|
const name = (m.profile?.displayName ?? '').toLowerCase();
|
|
const handle = (m.profile?.username ?? '').toLowerCase();
|
|
return name.includes(q) || handle.includes(q);
|
|
})
|
|
.slice(0, 8);
|
|
|
|
const [active, setActive] = useState(0);
|
|
useEffect(() => {
|
|
setActive(0);
|
|
}, [query]);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (matches.length === 0) return;
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActive((i) => (i + 1) % matches.length);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActive((i) => (i - 1 + matches.length) % matches.length);
|
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const pick = matches[active];
|
|
if (pick?.profile?.username) onSelect(pick.profile.username);
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey, true);
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey, true);
|
|
};
|
|
}, [matches, active, onSelect, onClose]);
|
|
|
|
if (matches.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
role="listbox"
|
|
aria-label="Mitglieder"
|
|
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
|
|
>
|
|
{matches.map((m, idx) => (
|
|
<MemberRow
|
|
key={m.userId}
|
|
member={m}
|
|
isActive={idx === active}
|
|
onActivate={() => setActive(idx)}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Extracted per-row component so we can call `useNickname` once per member
|
|
// at the top of THIS component (hooks can't run inside .map callbacks).
|
|
function MemberRow({
|
|
member,
|
|
isActive,
|
|
onActivate,
|
|
onSelect,
|
|
}: {
|
|
member: Member;
|
|
isActive: boolean;
|
|
onActivate: () => void;
|
|
onSelect: (username: string) => void;
|
|
}) {
|
|
const fallback = member.profile?.displayName ?? member.profile?.username ?? '?';
|
|
const name = useNickname(member.userId, fallback);
|
|
const handle = member.profile?.username ?? '';
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={isActive}
|
|
onMouseEnter={onActivate}
|
|
onClick={() => {
|
|
if (member.profile?.username) onSelect(member.profile.username);
|
|
}}
|
|
className={
|
|
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
|
(isActive
|
|
? 'bg-accent/20 text-fg'
|
|
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
|
|
}
|
|
>
|
|
<Avatar
|
|
displayName={name}
|
|
url={member.profile?.avatarUrl ?? null}
|
|
className="h-6 w-6 text-[10px]"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
|
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
|
</button>
|
|
);
|
|
}
|