Flip reaction emoji picker upward when it won't fit below

Reacting to a message near the bottom of the scrolled list opened a
picker that ran off-screen and couldn't be used -- placement was
hardcoded to "below" regardless of the trigger's actual position.
Now computed per-click from the trigger's bounding rect against
available viewport space, matching the composer's own picker (which
was already positioned dynamically, just always "above" since it's
pinned to the bottom of the screen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:03:01 -06:00
co-authored by Claude Sonnet 5
parent 1adb4fcbe0
commit c84c92446c
2 changed files with 22 additions and 3 deletions
+5
View File
@@ -11,6 +11,11 @@ interface EmojiPickerProps {
align?: 'left' | 'right'
}
// Kept in sync with .emoji-picker's max-height in EmojiPicker.css -- callers
// that compute placement dynamically (flipping above/below based on
// available viewport space) need this to know how much room to check for.
export const EMOJI_PICKER_MAX_HEIGHT = 380
function searchEmoji(query: string): string[] {
const q = query.trim().toLowerCase()
if (!q) return []
+17 -3
View File
@@ -3,7 +3,7 @@ import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
import { avatarUrlFor, displayNameFor, senderColorIndex } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker'
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox'
import { MessageContent } from './MessageContent'
@@ -79,6 +79,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null)
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
function displayNameForUserId(userId: string): string {
@@ -200,7 +201,20 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
<button
type="button"
className="message-reaction-trigger"
onClick={() => setReactingId(reactingId === msg.id ? null : msg.id)}
onClick={(e) => {
if (reactingId === msg.id) {
setReactingId(null)
return
}
// Flip upward when the picker wouldn't fit below the
// trigger -- a message near the bottom of the
// scrolled list otherwise opens a picker that runs
// off-screen and can't be used.
const rect = e.currentTarget.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
setReactionPlacement(spaceBelow < EMOJI_PICKER_MAX_HEIGHT ? 'above' : 'below')
setReactingId(msg.id)
}}
aria-label="Add reaction"
>
🙂
@@ -212,7 +226,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
setReactingId(null)
}}
onClose={() => setReactingId(null)}
placement="below"
placement={reactionPlacement}
align="right"
/>
)}