Private
Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
319b7097c2 | ||
|
|
0065b4ced3 |
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**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ds-chat"
|
||||
version = "2026.10.13"
|
||||
version = "2026.10.22"
|
||||
description = "DS Chat backend service"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "2026.10.13",
|
||||
"version": "2026.10.22",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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