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 && (