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
+3
-1
@@ -133,7 +133,9 @@ you'll see an error before it uploads.
|
||||
Hover over a message and click the 🙂 icon in its action row to react
|
||||
with an emoji. Reactions from everyone appear as small pills under the
|
||||
message with a count; click an existing pill to add or remove your own
|
||||
reaction to it. Hovering a pill shows who reacted.
|
||||
reaction to it. Hovering a pill shows who reacted. Once a message has
|
||||
reactions, a small **+🙂** button at the end of the row of pills lets you
|
||||
add a different emoji without going back to the action row.
|
||||
|
||||
Anyone can add a custom emoji: open the emoji picker (the 🙂 button, either
|
||||
in the composer or on a message) and click **+ Add** in the **Custom**
|
||||
|
||||
@@ -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