fix: use addEventListener for dropdown keyboard navigation

Moved keydown handler from inline React prop to useEffect with
addEventListener on the input ref. Uses a ref to track latest
dropdown state, avoiding stale closure issues that prevented
arrow keys from working. Added stopPropagation on Enter to
prevent form submission race. Guard in handleSubmit to bail
when a dropdown is open.
This commit is contained in:
2026-07-02 12:58:40 -04:00
parent aa80e354c9
commit 4ed2e5bfd0
+83 -68
View File
@@ -279,6 +279,7 @@ export function ChatArea() {
// Reset dropdown index when query changes
useEffect(() => { setDropdownIndex(0); }, [mentionQuery, commandQuery]);
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const [selectMode, setSelectMode] = useState(false);
const [showThreads, setShowThreads] = useState(false);
@@ -288,6 +289,9 @@ export function ChatArea() {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const lastTypingRef = useRef<number>(0);
const dropdownRef = useRef({ mentionQuery: null as string | null, commandQuery: null as string | null, dropdownIndex: 0 });
// ponytail: keep ref in sync with state so the stable keydown listener reads current values
dropdownRef.current = { mentionQuery, commandQuery, dropdownIndex };
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
@@ -491,8 +495,87 @@ export function ChatArea() {
inputEl.setSelectionRange(pos, pos);
};
// ponytail: stable keydown handler via addEventListener avoids stale closure issues
useEffect(() => {
const el = inputRef.current;
if (!el) return;
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault();
setShowKaomoji((prev) => !prev);
return;
}
const { mentionQuery: mq, commandQuery: cq, dropdownIndex: di } = dropdownRef.current;
const isDropdownOpen = mq !== null || cq !== null;
if (!isDropdownOpen) return;
const itemCount = mq !== null
? members.filter((m) =>
m.username.toLowerCase().includes(mq.toLowerCase()) ||
m.display_name?.toLowerCase().includes(mq.toLowerCase())
).slice(0, 6).length
: cq !== null
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
: 0;
if (itemCount === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setDropdownIndex((prev) => (prev + 1) % itemCount);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setDropdownIndex((prev) => (prev - 1 + itemCount) % itemCount);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (mq !== null) {
const q = mq.toLowerCase();
const filtered = members.filter((m) =>
m.username.toLowerCase().includes(q) ||
m.display_name?.toLowerCase().includes(q)
).slice(0, 6);
if (filtered[di]) {
handleMentionSelect(filtered[di].username);
}
} else if (cq !== null) {
const q = cq.toLowerCase();
const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8);
if (filtered[di]) {
const cmd = filtered[di];
setCommandQuery(null);
setDropdownIndex(0);
if (cmd.name === 'poll') {
setShowPollModal(true);
setInput('');
return;
}
const inputEl = inputRef.current;
const curVal = inputEl?.value || '';
const args = curVal.startsWith('/' + cmd.name + ' ')
? curVal.slice(cmd.name.length + 2).trim()
: '';
const text = cmd.transform(args, currentUser?.username || 'user');
sendMessage(activeChannelId!, text, replyToMessage?.id);
setInput('');
setReplyToMessage(null);
}
}
} else if (e.key === 'Escape') {
setMentionQuery(null);
setCommandQuery(null);
setDropdownIndex(0);
}
};
el.addEventListener('keydown', handler);
return () => el.removeEventListener('keydown', handler);
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
// ponytail: don't submit when a dropdown is handling keyboard input
if (mentionQuery !== null || commandQuery !== null) return;
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null);
try {
@@ -740,74 +823,6 @@ export function ChatArea() {
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId || slowmodeRemaining > 0}
onKeyDown={(e) => {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault();
setShowKaomoji((prev) => !prev);
return;
}
const isDropdownOpen = mentionQuery !== null || commandQuery !== null;
if (!isDropdownOpen) return;
// Compute item count for the active dropdown
const itemCount = mentionQuery !== null
? members.filter((m) =>
m.username.toLowerCase().includes(mentionQuery.toLowerCase()) ||
m.display_name?.toLowerCase().includes(mentionQuery.toLowerCase())
).slice(0, 6).length
: commandQuery !== null
? SLASH_COMMANDS.filter((c) => c.name.startsWith(commandQuery.toLowerCase())).slice(0, 8).length
: 0;
if (itemCount === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setDropdownIndex((prev) => (prev + 1) % itemCount);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setDropdownIndex((prev) => (prev - 1 + itemCount) % itemCount);
} else if (e.key === 'Enter') {
e.preventDefault();
if (mentionQuery !== null) {
const q = mentionQuery.toLowerCase();
const filtered = members.filter((m) =>
m.username.toLowerCase().includes(q) ||
m.display_name?.toLowerCase().includes(q)
).slice(0, 6);
if (filtered[dropdownIndex]) {
handleMentionSelect(filtered[dropdownIndex].username);
}
} else if (commandQuery !== null) {
const q = commandQuery.toLowerCase();
const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8);
if (filtered[dropdownIndex]) {
const cmd = filtered[dropdownIndex];
setCommandQuery(null);
setDropdownIndex(0);
// /poll opens modal instead of sending a message
if (cmd.name === 'poll') {
setShowPollModal(true);
setInput('');
return;
}
// ponytail: execute the command transform directly
const args = input.startsWith('/' + cmd.name + ' ')
? input.slice(cmd.name.length + 2).trim()
: '';
const text = cmd.transform(args, currentUser?.username || 'user');
sendMessage(activeChannelId!, text, replyToMessage?.id);
setInput('');
setReplyToMessage(null);
}
}
} else if (e.key === 'Escape') {
setMentionQuery(null);
setCommandQuery(null);
setDropdownIndex(0);
}
}}
/>
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />