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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user