From 0065b4ced30b7d9bc2c64b0d2e1c1fd38be8a435 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Thu, 22 Oct 2026 16:49:44 -0600 Subject: [PATCH] Add an add-reaction button to the reaction row (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- USER_GUIDE.md | 4 +- frontend/src/components/MessageList.css | 22 ++++ frontend/src/components/MessageList.test.tsx | 122 +++++++++++++++++++ frontend/src/components/MessageList.tsx | 64 +++++++--- 4 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/MessageList.test.tsx diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 99a08bc..7b877b4 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -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** diff --git a/frontend/src/components/MessageList.css b/frontend/src/components/MessageList.css index 0322350..e250cf7 100644 --- a/frontend/src/components/MessageList.css +++ b/frontend/src/components/MessageList.css @@ -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; +} diff --git a/frontend/src/components/MessageList.test.tsx b/frontend/src/components/MessageList.test.tsx new file mode 100644 index 0000000..80e5a36 --- /dev/null +++ b/frontend/src/components/MessageList.test.tsx @@ -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 { + 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( + {}} + 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() + }) +}) diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 3d04998..f91db0f 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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(null) const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null) const [reactingId, setReactingId] = useState(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(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, + 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({ ) })} +
+ + {reactingId === msg.id && reactingFrom === 'pills' && ( + { + onReact(msg.id, emoji) + setReactingId(null) + }} + onClose={() => setReactingId(null)} + placement={reactionPlacement} + align="left" + /> + )} +
)} @@ -332,25 +377,12 @@ export function MessageList({ - {reactingId === msg.id && ( + {reactingId === msg.id && reactingFrom === 'row' && ( { onReact(msg.id, emoji)