diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index ef6d76b8..72513a7c 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -361,14 +361,18 @@ export function ConversationView() { setMentionTrigger(null); setEmojiOpen(false); // Matches insertMentionTrigger's own restore below -- without an explicit setSelectionRange - // (deferred a frame so the textarea's DOM value has actually updated to `inserted + after` - // first), the cursor doesn't reliably land right after the just-inserted "@Name ", making it - // awkward to keep typing; a bare focus() alone doesn't move the caret to a specific offset. + // (deferred so the textarea's DOM value has actually updated to `inserted + after` first), the + // cursor doesn't reliably land right after the just-inserted "@Name ", making it awkward to keep + // typing; a bare focus() alone doesn't move the caret to a specific offset. Deferred via + // setTimeout(0), not requestAnimationFrame -- rAF callbacks are paused outright while the tab is + // backgrounded/hidden (confirmed live: an rAF scheduled right after a click never fired at all + // in a hidden tab, a setTimeout(0) scheduled the same way fired normally), and this restore needs + // to run regardless of whether the tab happens to be foregrounded at that exact moment. const el = textareaRef.current; - requestAnimationFrame(() => { + setTimeout(() => { el?.focus(); el?.setSelectionRange(inserted.length, inserted.length); - }); + }, 0); }; /** README ยง4.3's composer "@" toolbar icon -- same effect as typing '@' by hand, for groups. */ @@ -381,10 +385,10 @@ export function ConversationView() { setDraft(next); setMentionTrigger({ start: pos + insertion.length - 1, query: '' }); setEmojiOpen(false); - requestAnimationFrame(() => { + setTimeout(() => { el?.focus(); el?.setSelectionRange(pos + insertion.length, pos + insertion.length); - }); + }, 0); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -717,8 +721,24 @@ export function ConversationView() {
{ - setDraft(draft + emoji); + // Same restore-focus-and-caret dance as insertMentionTrigger/pickMention above + // -- clicking the emoji button moves DOM focus to it like any button click, + // and without this the textarea stays unfocused until the user clicks back + // into it (confirmed by a user report: Enter didn't send right after picking + // an emoji because the textarea itself had lost focus). Inserts at the actual + // caret position rather than always appending to the end, matching how a + // mention insertion already behaves, so the two don't disagree about where + // "here" is. setTimeout(0), not requestAnimationFrame -- see insertMentionTrigger's + // own comment for why (rAF never fires at all while the tab is hidden). + const el = textareaRef.current; + const pos = el?.selectionStart ?? draft.length; + const next = draft.slice(0, pos) + emoji + draft.slice(pos); + setDraft(next); setEmojiOpen(false); + setTimeout(() => { + el?.focus(); + el?.setSelectionRange(pos + emoji.length, pos + emoji.length); + }, 0); }} />