From 4ed2e5bfd043b128f2cefde1b4d73fe9b37fbe1f Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 2 Jul 2026 12:58:40 -0400 Subject: [PATCH] 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. --- web/src/components/ChatArea.tsx | 151 ++++++++++++++++++-------------- 1 file changed, 83 insertions(+), 68 deletions(-) diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index e857dcf..af62f42 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -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(null); const inputRef = useRef(null); const lastTypingRef = useRef(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 && (