Add emoji picker and message reactions (Gitea issue #11)

Composer gains an emoji picker (static curated unicode list, insert at
cursor position) and messages gain Slack/Mattermost-style reactions:
react with any emoji, toggle off by reacting again, see who reacted via
a tooltip on each pill.

Backend: MessageReaction model (unique on message_id+user_id+emoji backs
toggle semantics), WS "reaction" envelope broadcasts the full recomputed
reaction list per message (same approach as message edits), REST message
list embeds reactions so a reload doesn't lose state that only arrived
over WS.

Frontend: shared EmojiPicker component (anchored popover, Escape/outside-
click dismiss via new useEscapeKey hook) used by both the composer and a
new hover-revealed reaction trigger on each message row.
This commit is contained in:
2026-08-14 15:37:59 -06:00
parent f2a59f798b
commit c6f90d49fc
22 changed files with 854 additions and 34 deletions
+15 -2
View File
@@ -54,6 +54,13 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
),
)
} else if (envelope.type === 'reaction_update') {
setHistory((prev) =>
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
)
setLive((prev) =>
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
)
} else if (envelope.type === 'error') {
setWsError(envelope.detail)
}
@@ -61,7 +68,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
const { connected, send, sendEdit } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
const { connected, send, sendEdit, sendReaction } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
return (
<section className="chat-pane">
@@ -99,7 +106,13 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
</p>
)}
<MessageList roomId={room.id} messages={[...history, ...live]} members={members} onEdit={sendEdit} />
<MessageList
roomId={room.id}
messages={[...history, ...live]}
members={members}
onEdit={sendEdit}
onReact={sendReaction}
/>
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} />
</section>
)
+29
View File
@@ -53,6 +53,35 @@
display: none;
}
.composer-emoji-wrap {
position: relative;
flex: none;
}
.composer-emoji-trigger {
width: 36px;
height: 36px;
flex: none;
border-radius: var(--radius);
background: transparent;
border: 1px solid var(--ds-border);
font-size: 1rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.composer-emoji-trigger:hover:not(:disabled) {
border-color: var(--ds-accent);
}
.composer-emoji-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.composer-attach {
width: 36px;
height: 36px;
+39
View File
@@ -1,6 +1,7 @@
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomImage } from '../api/rooms'
import { EmojiPicker } from './EmojiPicker'
import './Composer.css'
interface ComposerProps {
@@ -15,6 +16,7 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const online = useOnlineStatus()
@@ -69,6 +71,24 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
})
}
function insertEmoji(emoji: string) {
const el = textareaRef.current
setEmojiPickerOpen(false)
if (!el) {
setValue((v) => v + emoji)
return
}
const start = el.selectionStart ?? value.length
const end = el.selectionEnd ?? value.length
setValue(value.slice(0, start) + emoji + value.slice(end))
requestAnimationFrame(() => {
el.focus()
const cursor = start + emoji.length
el.setSelectionRange(cursor, cursor)
autoGrow()
})
}
return (
<div className="composer">
{pendingImage && (
@@ -116,6 +136,25 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
</svg>
)}
</button>
<div className="composer-emoji-wrap">
<button
type="button"
className="composer-emoji-trigger"
onClick={() => setEmojiPickerOpen((v) => !v)}
disabled={disabled}
aria-label="Insert an emoji"
>
🙂
</button>
{emojiPickerOpen && (
<EmojiPicker
onPick={insertEmoji}
onClose={() => setEmojiPickerOpen(false)}
placement="above"
align="left"
/>
)}
</div>
<textarea
ref={textareaRef}
rows={1}
+65
View File
@@ -0,0 +1,65 @@
.emoji-picker-scrim {
position: fixed;
inset: 0;
z-index: 30;
}
.emoji-picker {
position: absolute;
z-index: 31;
width: 280px;
max-height: 320px;
overflow-y: auto;
background: var(--card-bg);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: var(--sp-2);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.emoji-picker-below {
top: calc(100% + 8px);
}
.emoji-picker-above {
bottom: calc(100% + 8px);
}
.emoji-picker-left {
left: 0;
}
.emoji-picker-right {
right: 0;
}
.emoji-picker-category-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ds-muted);
padding: 4px 4px 2px;
}
.emoji-picker-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
}
.emoji-picker-item {
background: transparent;
border: none;
border-radius: 6px;
font-size: 1.1rem;
line-height: 1;
padding: 6px 0;
cursor: pointer;
}
.emoji-picker-item:hover {
background: var(--ds-surface-2);
}
+43
View File
@@ -0,0 +1,43 @@
import { useEscapeKey } from '../hooks/useEscapeKey'
import { EMOJI_CATEGORIES } from '../lib/emoji'
import './EmojiPicker.css'
interface EmojiPickerProps {
onPick: (emoji: string) => void
onClose: () => void
placement?: 'above' | 'below'
align?: 'left' | 'right'
}
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
useEscapeKey(onClose)
return (
<>
<div className="emoji-picker-scrim" onClick={onClose} />
<div
className={`emoji-picker emoji-picker-${placement} emoji-picker-${align}`}
role="menu"
>
{EMOJI_CATEGORIES.map((category) => (
<div key={category.label} className="emoji-picker-category">
<div className="emoji-picker-category-label">{category.label}</div>
<div className="emoji-picker-grid">
{category.emoji.map((emoji) => (
<button
key={emoji}
type="button"
role="menuitem"
className="emoji-picker-item"
onClick={() => onPick(emoji)}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</>
)
}
+2 -8
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEscapeKey } from '../hooks/useEscapeKey'
import './ImageLightbox.css'
interface ImageLightboxProps {
@@ -7,13 +7,7 @@ interface ImageLightboxProps {
}
export function ImageLightbox({ src, onClose }: ImageLightboxProps) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])
useEscapeKey(onClose)
return (
<div className="image-lightbox" onClick={onClose}>
+56 -3
View File
@@ -76,11 +76,20 @@
font-size: 0.78rem;
}
.message-edit-link {
.message-row-actions {
display: none;
position: absolute;
top: 2px;
right: var(--sp-2);
align-items: center;
gap: 4px;
}
.message-row:hover .message-row-actions {
display: flex;
}
.message-edit-link {
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
color: var(--ds-muted);
@@ -95,8 +104,52 @@
border-color: var(--ds-accent);
}
.message-row:hover .message-edit-link {
display: inline;
.message-reaction-wrap {
position: relative;
}
.message-reaction-trigger {
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
color: var(--ds-muted);
font-size: 0.78rem;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
}
.message-reaction-trigger:hover {
border-color: var(--ds-accent);
}
.message-reaction-pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.message-reaction-pill {
display: flex;
align-items: center;
gap: 4px;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius-pill);
color: var(--ds-muted);
font-size: 0.76rem;
padding: 2px 8px;
cursor: pointer;
}
.message-reaction-pill:hover {
border-color: var(--ds-accent);
}
.message-reaction-pill-mine {
border-color: var(--ds-accent);
color: var(--ds-text);
}
.message-edit-input {
+61 -10
View File
@@ -3,6 +3,7 @@ import { getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
import { senderColorIndex } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker'
import { ImageLightbox } from './ImageLightbox'
import { UserAvatar } from './UserAvatar'
import './MessageList.css'
@@ -12,14 +13,20 @@ interface MessageListProps {
messages: (Message | ChatMessageEnvelope)[]
members: RoomMember[]
onEdit: (messageId: string, content: string) => void
onReact: (messageId: string, emoji: string) => void
}
export function MessageList({ roomId, messages, members, onEdit }: MessageListProps) {
export function MessageList({ roomId, messages, members, onEdit, onReact }: MessageListProps) {
const { user } = useAuth()
const bottomRef = useRef<HTMLDivElement>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null)
function usernameFor(userId: string): string {
return members.find((m) => m.user_id === userId)?.username ?? 'someone'
}
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: 'end' })
@@ -92,18 +99,62 @@ export function MessageList({ roomId, messages, members, onEdit }: MessageListPr
{msg.edited_at && <span className="message-edited"> (edited)</span>}
</div>
)}
{msg.reactions.length > 0 && (
<div className="message-reaction-pills">
{msg.reactions.map((r) => {
const mineReaction = !!user && r.user_ids.includes(user.id)
return (
<button
key={r.emoji}
type="button"
className={`message-reaction-pill${mineReaction ? ' message-reaction-pill-mine' : ''}`}
title={r.user_ids.map(usernameFor).join(', ')}
onClick={() => onReact(msg.id, r.emoji)}
>
<span>{r.emoji}</span>
<span>{r.count}</span>
</button>
)
})}
</div>
)}
</>
)}
</div>
{mine && !editing && (
<button
type="button"
className="message-edit-link"
onClick={() => startEdit(msg)}
aria-label="Edit message"
>
Edit
</button>
{!editing && (
<div className="message-row-actions">
<div className="message-reaction-wrap">
<button
type="button"
className="message-reaction-trigger"
onClick={() => setReactingId(reactingId === msg.id ? null : msg.id)}
aria-label="Add reaction"
>
🙂
</button>
{reactingId === msg.id && (
<EmojiPicker
onPick={(emoji) => {
onReact(msg.id, emoji)
setReactingId(null)
}}
onClose={() => setReactingId(null)}
placement="below"
align="right"
/>
)}
</div>
{mine && (
<button
type="button"
className="message-edit-link"
onClick={() => startEdit(msg)}
aria-label="Edit message"
>
Edit
</button>
)}
</div>
)}
</div>
)