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)