Author SHA1 Message Date
ksmithandClaude Sonnet 5.5 319b7097c2 Bump to v2026.10.22
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:49:44 -06:00
ksmithandClaude Sonnet 5.5 0065b4ced3 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>
2026-10-22 16:49:44 -06:00
6 changed files with 197 additions and 19 deletions

No files matched your search

+3 -1
View File
@@ -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 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 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 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 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** in the composer or on a message) and click **+ Add** in the **Custom**
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ds-chat" name = "ds-chat"
version = "2026.10.13" version = "2026.10.22"
description = "DS Chat backend service" description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" } license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "2026.10.13", "version": "2026.10.22",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
+22
View File
@@ -391,6 +391,7 @@
} }
.message-reaction-pills { .message-reaction-pills {
position: relative;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
@@ -430,3 +431,24 @@
width: 100%; width: 100%;
resize: vertical; 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()
})
})
+48 -16
View File
@@ -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 { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@@ -146,6 +146,10 @@ export function MessageList({
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null) const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null) const [videoLightbox, setVideoLightbox] = useState<{ src: string; filename: string } | null>(null)
const [reactingId, setReactingId] = useState<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 [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null) const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]) 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' 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(() => { useEffect(() => {
pinnedToBottomRef.current = true pinnedToBottomRef.current = true
bottomRef.current?.scrollIntoView({ block: 'end' }) bottomRef.current?.scrollIntoView({ block: 'end' })
@@ -321,6 +344,28 @@ export function MessageList({
</button> </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> </div>
)} )}
</> </>
@@ -332,25 +377,12 @@ export function MessageList({
<button <button
type="button" type="button"
className="message-reaction-trigger" className="message-reaction-trigger"
onClick={(e) => { onClick={(e) => toggleReactionPicker(e, msg.id, 'row')}
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" aria-label="Add reaction"
> >
🙂 🙂
</button> </button>
{reactingId === msg.id && ( {reactingId === msg.id && reactingFrom === 'row' && (
<EmojiPicker <EmojiPicker
onPick={(emoji) => { onPick={(emoji) => {
onReact(msg.id, emoji) onReact(msg.id, emoji)