Private
Public Access
Add an add-reaction button to the reaction row (#78)
Adding to a message that already had reactions meant going back up to the hover row's 🙂. A "+🙂" button now sits at the end of the row of reaction pills, always visible once the message has at least one reaction, and opens the same emoji picker. The picker anchors to the left edge of the pill row rather than the button: the button moves right with every added reaction, and a picker hanging off it ran past the edge of a phone screen. Both buttons share the picker's open state, so only one picker is ever open at a time. MessageList had no tests; this adds a few covering the new button. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b52db0ebe0
commit
0065b4ced3
4 files changed
+195
-17
No files matched your search
@@ -391,6 +391,7 @@
|
||||
}
|
||||
|
||||
.message-reaction-pills {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
@@ -430,3 +431,24 @@
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* The picker anchors to the row of pills, not to this button: the button's
|
||||
position moves right with every added reaction, and a picker hanging
|
||||
off it ran past the edge of a phone screen. (No `position` here on
|
||||
purpose -- .message-reaction-wrap's relative would re-anchor it.) */
|
||||
.message-reaction-add-wrap {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* #78: always visible once a message has reactions, so adding to the pile
|
||||
doesn't need the hover-row button. Same shape as the pills it sits
|
||||
beside, kept quieter so it doesn't read as another reaction. */
|
||||
.message-reaction-add {
|
||||
gap: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.message-reaction-add:hover,
|
||||
.message-reaction-add:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '../types'
|
||||
import { MessageList } from './MessageList'
|
||||
|
||||
vi.mock('../context/AuthContext', () => ({
|
||||
useAuth: () => ({ user: { id: 'u1', username: 'alice', emoji_scale: 'normal' } }),
|
||||
}))
|
||||
vi.mock('../context/CustomEmojiContext', () => ({
|
||||
useCustomEmoji: () => ({ byShortcode: new Map(), list: [], refresh: () => {} }),
|
||||
}))
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom doesn't implement it; MessageList scrolls to the bottom on mount.
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function message(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 'm1',
|
||||
room_id: 'r1',
|
||||
user_id: 'u2',
|
||||
username: 'bob',
|
||||
content: 'hello',
|
||||
image_id: null,
|
||||
file: null,
|
||||
link_preview: null,
|
||||
reactions: [],
|
||||
created_at: '2026-10-20T16:46:00Z',
|
||||
edited_at: null,
|
||||
deleted_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(messages: Message[], onReact = vi.fn()) {
|
||||
render(
|
||||
<MessageList
|
||||
roomId="r1"
|
||||
messages={messages}
|
||||
members={[]}
|
||||
myRooms={new Map()}
|
||||
onEdit={() => {}}
|
||||
onReact={onReact}
|
||||
onDelete={() => {}}
|
||||
/>,
|
||||
)
|
||||
return onReact
|
||||
}
|
||||
|
||||
const reacted = [{ emoji: '🎉', count: 1, user_ids: ['u2'] }]
|
||||
|
||||
describe('MessageList add-reaction button (#78)', () => {
|
||||
it('shows one in the reaction row only when the message already has reactions', () => {
|
||||
renderList([
|
||||
message({ id: 'm1', content: 'with', reactions: reacted }),
|
||||
message({ id: 'm2', content: 'without', reactions: [] }),
|
||||
])
|
||||
const pills = document.querySelectorAll('.message-reaction-pills')
|
||||
expect(pills).toHaveLength(1)
|
||||
expect(within(pills[0] as HTMLElement).getByRole('button', { name: 'Add reaction' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the hover-row button on every message', () => {
|
||||
renderList([
|
||||
message({ id: 'm1', reactions: reacted }),
|
||||
message({ id: 'm2', content: 'plain', reactions: [] }),
|
||||
])
|
||||
// Two on the reacted message (row + pills), one on the plain one.
|
||||
expect(screen.getAllByRole('button', { name: 'Add reaction' })).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('opens the picker next to the pills and reacts with the pick', () => {
|
||||
const onReact = renderList([message({ reactions: reacted })])
|
||||
const pillsAdd = within(document.querySelector('.message-reaction-pills') as HTMLElement).getByRole(
|
||||
'button',
|
||||
{ name: 'Add reaction' },
|
||||
)
|
||||
fireEvent.click(pillsAdd)
|
||||
|
||||
const pickers = document.querySelectorAll('.emoji-picker')
|
||||
expect(pickers).toHaveLength(1)
|
||||
expect(document.querySelector('.message-reaction-pills .emoji-picker')).not.toBeNull()
|
||||
|
||||
fireEvent.click(within(pickers[0] as HTMLElement).getAllByRole('menuitem')[0])
|
||||
expect(onReact).toHaveBeenCalledTimes(1)
|
||||
expect(onReact.mock.calls[0][0]).toBe('m1')
|
||||
expect(document.querySelector('.emoji-picker')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens only one picker at a time, in whichever place was used last', () => {
|
||||
renderList([message({ reactions: reacted })])
|
||||
const [rowButton, pillsButton] = [
|
||||
document.querySelector('.message-row-actions .message-reaction-trigger') as HTMLElement,
|
||||
document.querySelector('.message-reaction-pills .message-reaction-add') as HTMLElement,
|
||||
]
|
||||
|
||||
fireEvent.click(pillsButton)
|
||||
expect(document.querySelectorAll('.emoji-picker')).toHaveLength(1)
|
||||
|
||||
fireEvent.click(rowButton)
|
||||
expect(document.querySelectorAll('.emoji-picker')).toHaveLength(1)
|
||||
expect(document.querySelector('.message-row-actions .emoji-picker')).not.toBeNull()
|
||||
expect(document.querySelector('.message-reaction-pills .emoji-picker')).toBeNull()
|
||||
})
|
||||
|
||||
it('clicking the same button again closes its picker', () => {
|
||||
renderList([message({ reactions: reacted })])
|
||||
const pillsButton = document.querySelector('.message-reaction-pills .message-reaction-add') as HTMLElement
|
||||
fireEvent.click(pillsButton)
|
||||
expect(document.querySelector('.emoji-picker')).not.toBeNull()
|
||||
fireEvent.click(pillsButton)
|
||||
expect(document.querySelector('.emoji-picker')).toBeNull()
|
||||
})
|
||||
|
||||
it('is not offered on a deleted message', () => {
|
||||
renderList([message({ content: null, deleted_at: '2026-10-21T10:00:00Z', reactions: [] })])
|
||||
expect(screen.queryByRole('button', { name: 'Add reaction' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
@@ -146,6 +146,10 @@ export function MessageList({
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
||||
const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null)
|
||||
const [reactingId, setReactingId] = useState<string | null>(null)
|
||||
// #78: a message has two buttons that open the same picker (the hover-row
|
||||
// one and the one at the end of its reaction pills); this says which one
|
||||
// the picker belongs to, so it renders next to the button that was used.
|
||||
const [reactingFrom, setReactingFrom] = useState<'row' | 'pills'>('row')
|
||||
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
|
||||
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
|
||||
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members])
|
||||
@@ -155,6 +159,25 @@ export function MessageList({
|
||||
return member?.display_name || member?.username || 'someone'
|
||||
}
|
||||
|
||||
function toggleReactionPicker(
|
||||
e: ReactMouseEvent<HTMLButtonElement>,
|
||||
messageId: string,
|
||||
from: 'row' | 'pills',
|
||||
) {
|
||||
if (reactingId === messageId && reactingFrom === from) {
|
||||
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')
|
||||
setReactingFrom(from)
|
||||
setReactingId(messageId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
pinnedToBottomRef.current = true
|
||||
bottomRef.current?.scrollIntoView({ block: 'end' })
|
||||
@@ -321,6 +344,28 @@ export function MessageList({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="message-reaction-add-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="message-reaction-pill message-reaction-add"
|
||||
onClick={(e) => toggleReactionPicker(e, msg.id, 'pills')}
|
||||
aria-label="Add reaction"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
<span aria-hidden="true">🙂</span>
|
||||
</button>
|
||||
{reactingId === msg.id && reactingFrom === 'pills' && (
|
||||
<EmojiPicker
|
||||
onPick={(emoji) => {
|
||||
onReact(msg.id, emoji)
|
||||
setReactingId(null)
|
||||
}}
|
||||
onClose={() => setReactingId(null)}
|
||||
placement={reactionPlacement}
|
||||
align="left"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -332,25 +377,12 @@ export function MessageList({
|
||||
<button
|
||||
type="button"
|
||||
className="message-reaction-trigger"
|
||||
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)
|
||||
}}
|
||||
onClick={(e) => toggleReactionPicker(e, msg.id, 'row')}
|
||||
aria-label="Add reaction"
|
||||
>
|
||||
🙂
|
||||
</button>
|
||||
{reactingId === msg.id && (
|
||||
{reactingId === msg.id && reactingFrom === 'row' && (
|
||||
<EmojiPicker
|
||||
onPick={(emoji) => {
|
||||
onReact(msg.id, emoji)
|
||||
|
||||
Reference in new issue
Block a user