Private
Public Access
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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function useEscapeKey(onEscape: () => void) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onEscape()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onEscape])
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface EmojiCategory {
|
||||
label: string
|
||||
emoji: string[]
|
||||
}
|
||||
|
||||
export const EMOJI_CATEGORIES: EmojiCategory[] = [
|
||||
{
|
||||
label: 'Smileys',
|
||||
emoji: [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃',
|
||||
'😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😋', '😛', '😜',
|
||||
'🤪', '🤨', '🧐', '🤓', '😎', '🥳', '😏', '😒', '😞', '😔',
|
||||
'😢', '😭', '😤', '😠', '😡', '🤯', '😳', '🥵', '🥶', '😱',
|
||||
'😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
|
||||
'😐', '😑', '😬', '🙄', '😯', '😴', '🤤', '😪', '😵', '🤢',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Gestures',
|
||||
emoji: [
|
||||
'👍', '👎', '👏', '🙌', '🙏', '🤝', '👋', '✌️', '🤞', '🤟',
|
||||
'🤘', '👌', '🤙', '☝️', '👆', '👇', '👈', '👉', '💪', '🫡',
|
||||
'🫶', '🤲',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Hearts',
|
||||
emoji: [
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
|
||||
'❤️🔥', '💯', '✨', '💖', '💕', '💗',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Objects & Symbols',
|
||||
emoji: [
|
||||
'🔥', '🎉', '🎊', '🎂', '🎁', '🚀', '⭐', '🌟', '✅', '❌',
|
||||
'⚡', '💡', '📌', '📎', '🐛', '🍕', '🍔', '☕', '🍺', '🎯',
|
||||
'⏰', '💰', '🔒', '🔑', '📢', '❓', '❗', '💬', '👀', '🎈',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Animals & Nature',
|
||||
emoji: [
|
||||
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯',
|
||||
'🦁', '🐸', '🐵', '🦄', '🐔', '🐢', '🐍', '🌈', '☀️', '🌙',
|
||||
'⛅', '🌧️', '❄️', '🌸', '🌵', '🌴',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const ALL_EMOJI = EMOJI_CATEGORIES.flatMap((c) => c.emoji)
|
||||
@@ -51,6 +51,12 @@ export interface MyInvite extends Invite {
|
||||
invited_by_username: string
|
||||
}
|
||||
|
||||
export interface ReactionSummary {
|
||||
emoji: string
|
||||
count: number
|
||||
user_ids: string[]
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
room_id: string
|
||||
@@ -58,6 +64,7 @@ export interface Message {
|
||||
username: string
|
||||
content: string | null
|
||||
image_id: string | null
|
||||
reactions: ReactionSummary[]
|
||||
created_at: string
|
||||
edited_at: string | null
|
||||
}
|
||||
@@ -70,6 +77,7 @@ export interface ChatMessageEnvelope {
|
||||
username: string
|
||||
content: string | null
|
||||
image_id: string | null
|
||||
reactions: ReactionSummary[]
|
||||
created_at: string
|
||||
edited_at: string | null
|
||||
}
|
||||
@@ -82,6 +90,13 @@ export interface ChatMessageUpdateEnvelope {
|
||||
edited_at: string | null
|
||||
}
|
||||
|
||||
export interface ChatReactionUpdateEnvelope {
|
||||
type: 'reaction_update'
|
||||
id: string
|
||||
room_id: string
|
||||
reactions: ReactionSummary[]
|
||||
}
|
||||
|
||||
export interface ChatJoinedEnvelope {
|
||||
type: 'joined'
|
||||
room_id: string
|
||||
@@ -95,6 +110,7 @@ export interface ChatErrorEnvelope {
|
||||
export type ServerEnvelope =
|
||||
| ChatMessageEnvelope
|
||||
| ChatMessageUpdateEnvelope
|
||||
| ChatReactionUpdateEnvelope
|
||||
| ChatJoinedEnvelope
|
||||
| ChatErrorEnvelope
|
||||
|
||||
|
||||
@@ -96,5 +96,11 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
|
||||
ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content }))
|
||||
}, [roomId])
|
||||
|
||||
return { connected, send, sendEdit }
|
||||
const sendReaction = useCallback((messageId: string, emoji: string) => {
|
||||
const ws = socketRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'reaction', room_id: roomId, message_id: messageId, emoji }))
|
||||
}, [roomId])
|
||||
|
||||
return { connected, send, sendEdit, sendReaction }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user