feat: chat — edit/delete messages + emoji reactions

- Migration 074: edited_at, deleted_at columns + chat_reactions table
- Backend: PATCH edit (own only), DELETE soft-delete (own/admin), POST toggle reaction
  fetchMessage helper returns full message with aggregated reactions in one query
- API types: ChatReaction interface, editedAt/deletedAt/reactions in ChatMessage
- ChatWidget: hover toolbar (😊 react · ✏️ edit · 🗑️ delete), inline edit with
  save/cancel (Enter/Esc), reaction emoji picker (8 emojis), reaction pills below
  messages (click to toggle), '· изм.' label on edited messages, soft-deleted
  messages show 'Сообщение удалено' placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 12:31:17 +03:00
parent 7ccf752429
commit a29f58282f
4 changed files with 440 additions and 93 deletions

View File

@@ -3,7 +3,7 @@ import {
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip,
} from 'lucide-react'
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult } from '../../lib/api'
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api'
import type { User } from '../../types'
import { useAuth } from '../../contexts/AuthContext'
import { cn } from '../../lib/utils'
@@ -88,6 +88,12 @@ export function ChatWidget() {
const [attachment, setAttachment] = useState<File | null>(null)
const [attachPreview, setAttachPreview] = useState<string | null>(null)
// Edit / delete / reactions
const [hoveredMsgId, setHoveredMsgId] = useState<string | null>(null)
const [editingMsgId, setEditingMsgId] = useState<string | null>(null)
const [editText, setEditText] = useState('')
const [reactionPickerMsgId, setReactionPickerMsgId] = useState<string | null>(null)
// Search state
const [searchQuery, setSearchQuery] = useState('')
const [searchUsers, setSearchUsers] = useState<User[]>([])
@@ -288,6 +294,41 @@ export function ChatWidget() {
saveSettings(next)
}
const startEdit = (msg: ChatMessage) => {
setEditingMsgId(msg.id)
setEditText(msg.text)
setReactionPickerMsgId(null)
}
const saveEdit = async (msg: ChatMessage) => {
if (!editText.trim() || !activeRoom) return
try {
const updated = await api.chat.editMessage(slug, activeRoom.id, msg.id, editText)
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
} catch { /* ignore */ }
setEditingMsgId(null)
}
const cancelEdit = () => setEditingMsgId(null)
const deleteMsg = async (msg: ChatMessage) => {
if (!activeRoom) return
try {
await api.chat.deleteMessage(slug, activeRoom.id, msg.id)
setMessages(prev => prev.map(m => m.id === msg.id ? { ...m, deletedAt: new Date().toISOString(), text: '', attachmentUrl: null } : m))
} catch { /* ignore */ }
setHoveredMsgId(null)
}
const toggleReaction = async (msg: ChatMessage, emoji: string) => {
if (!activeRoom) return
setReactionPickerMsgId(null)
try {
const updated = await api.chat.toggleReaction(slug, activeRoom.id, msg.id, emoji)
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
} catch { /* ignore */ }
}
const goBack = () => {
if (view === 'messages') { setView('rooms'); setActiveRoom(null) }
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
@@ -570,54 +611,28 @@ export function ChatWidget() {
{activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
</div>
) : (
messages.map(msg => {
const isSystem = msg.isSystem
const isOwn = !isSystem && msg.senderId === user?.id
return (
<div key={msg.id} className={cn('flex gap-2', isOwn && 'flex-row-reverse')}>
{!isOwn && (
isSystem
? <SystemAvatar size={24} />
: <Avatar name={msg.senderName} size={24} />
)}
<div className={cn('max-w-[80%]', isOwn && 'items-end flex flex-col')}>
{!isOwn && (
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
</p>
)}
<div className={cn(
'rounded-2xl text-sm overflow-hidden',
isSystem
? 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-tl-sm'
: isOwn
? 'bg-brand-600 rounded-tr-sm'
: 'bg-slate-100 dark:bg-slate-700 rounded-tl-sm',
)}>
{msg.attachmentUrl && (
<a href={msg.attachmentUrl} target="_blank" rel="noreferrer">
<img
src={msg.attachmentUrl}
alt="вложение"
className="max-w-full rounded-t-2xl block"
style={{ maxHeight: 180, objectFit: 'cover', width: '100%' }}
/>
</a>
)}
{msg.text && (
<p className={cn(
'px-3 py-2',
isSystem
? 'text-amber-900 dark:text-amber-200'
: isOwn ? 'text-white' : 'text-slate-800 dark:text-slate-200',
)}>{msg.text}</p>
)}
</div>
<p className="text-[10px] text-slate-400 mt-0.5 mx-1">{fmtTime(msg.createdAt)}</p>
</div>
</div>
)
})
messages.map(msg => (
<MessageRow
key={msg.id}
msg={msg}
isOwn={!msg.isSystem && msg.senderId === user?.id}
isHovered={hoveredMsgId === msg.id}
isEditing={editingMsgId === msg.id}
editText={editText}
showReactionPicker={reactionPickerMsgId === msg.id}
canModify={activeRoom?.type !== 'notifications'}
onMouseEnter={() => setHoveredMsgId(msg.id)}
onMouseLeave={() => { if (reactionPickerMsgId !== msg.id) setHoveredMsgId(null) }}
onEdit={() => startEdit(msg)}
onDelete={() => deleteMsg(msg)}
onEditTextChange={setEditText}
onSaveEdit={() => saveEdit(msg)}
onCancelEdit={cancelEdit}
onToggleReactionPicker={() => setReactionPickerMsgId(v => v === msg.id ? null : msg.id)}
onReaction={emoji => toggleReaction(msg, emoji)}
onCloseReactionPicker={() => { setReactionPickerMsgId(null); setHoveredMsgId(null) }}
/>
))
)}
<div ref={messagesEndRef} />
</div>
@@ -755,6 +770,178 @@ function ToggleRow({ icon, label, checked, onChange }: {
)
}
// ── Emoji set ─────────────────────────────────────────────────────────────
const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅']
// ── MessageRow ─────────────────────────────────────────────────────────────
function MessageRow({
msg, isOwn, isHovered, isEditing, editText, showReactionPicker, canModify,
onMouseEnter, onMouseLeave, onEdit, onDelete,
onEditTextChange, onSaveEdit, onCancelEdit,
onToggleReactionPicker, onReaction, onCloseReactionPicker,
}: {
msg: ChatMessage
isOwn: boolean
isHovered: boolean
isEditing: boolean
editText: string
showReactionPicker: boolean
canModify: boolean
onMouseEnter: () => void
onMouseLeave: () => void
onEdit: () => void
onDelete: () => void
onEditTextChange: (v: string) => void
onSaveEdit: () => void
onCancelEdit: () => void
onToggleReactionPicker: () => void
onReaction: (emoji: string) => void
onCloseReactionPicker: () => void
}) {
const isDeleted = !!msg.deletedAt
const isSystem = msg.isSystem
return (
<div
className={cn('flex gap-2 group relative', isOwn && 'flex-row-reverse')}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* Avatar */}
{!isOwn && (
isSystem
? <SystemAvatar size={24} />
: <Avatar name={msg.senderName} size={24} />
)}
<div className={cn('max-w-[80%]', isOwn && 'items-end flex flex-col')}>
{/* Sender name */}
{!isOwn && !isDeleted && (
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
</p>
)}
{/* Bubble */}
{isDeleted ? (
<p className="px-3 py-2 text-sm italic text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-700/50 rounded-2xl">
Сообщение удалено
</p>
) : isEditing ? (
<div className="w-48">
<textarea
value={editText}
onChange={e => onEditTextChange(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSaveEdit() }
if (e.key === 'Escape') onCancelEdit()
}}
autoFocus
rows={2}
className="w-full resize-none rounded-xl px-3 py-2 text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 outline-none border border-brand-400"
/>
<div className="flex gap-1.5 mt-1">
<button onClick={onSaveEdit} className="text-[11px] px-2 py-0.5 rounded bg-brand-600 text-white hover:bg-brand-700">Сохранить</button>
<button onClick={onCancelEdit} className="text-[11px] px-2 py-0.5 rounded bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-300">Отмена</button>
</div>
</div>
) : (
<div className={cn(
'rounded-2xl text-sm overflow-hidden',
isSystem
? 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-tl-sm'
: isOwn
? 'bg-brand-600 rounded-tr-sm'
: 'bg-slate-100 dark:bg-slate-700 rounded-tl-sm',
)}>
{msg.attachmentUrl && (
<a href={msg.attachmentUrl} target="_blank" rel="noreferrer">
<img src={msg.attachmentUrl} alt="вложение" className="max-w-full rounded-t-2xl block" style={{ maxHeight: 180, objectFit: 'cover', width: '100%' }} />
</a>
)}
{msg.text && (
<p className={cn('px-3 py-2', isSystem ? 'text-amber-900 dark:text-amber-200' : isOwn ? 'text-white' : 'text-slate-800 dark:text-slate-200')}>
{msg.text}
</p>
)}
</div>
)}
{/* Meta row: time + edited */}
{!isDeleted && (
<div className={cn('flex items-center gap-1 mt-0.5 mx-1', isOwn && 'flex-row-reverse')}>
<p className="text-[10px] text-slate-400">{fmtTime(msg.createdAt)}</p>
{msg.editedAt && <p className="text-[10px] text-slate-400">· изм.</p>}
</div>
)}
{/* Reactions */}
{!isDeleted && msg.reactions && msg.reactions.length > 0 && (
<div className={cn('flex flex-wrap gap-1 mt-1', isOwn && 'justify-end')}>
{(msg.reactions as ChatReaction[]).map(r => (
<button
key={r.emoji}
onClick={() => onReaction(r.emoji)}
className={cn(
'flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs transition-colors',
r.hasOwn
? 'bg-brand-100 dark:bg-brand-900/40 border border-brand-400 text-brand-700 dark:text-brand-300'
: 'bg-slate-100 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-200',
)}
>
{r.emoji} <span className="text-[10px] font-medium">{r.count}</span>
</button>
))}
</div>
)}
</div>
{/* Action toolbar — appears on hover */}
{!isDeleted && !isEditing && !isSystem && canModify && isHovered && (
<div className={cn(
'absolute top-0 flex items-center gap-0.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl shadow-md px-1 py-0.5 z-10',
isOwn ? 'right-full mr-1' : 'left-full ml-1',
)}>
{/* Reaction */}
<button onClick={onToggleReactionPicker} title="Реакция" className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg text-slate-500 hover:text-brand-600 transition-colors text-sm">
😊
</button>
{/* Edit — own only */}
{isOwn && (
<button onClick={onEdit} title="Редактировать" className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg text-slate-500 hover:text-brand-600 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
)}
{/* Delete — own only */}
{isOwn && (
<button onClick={onDelete} title="Удалить" className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg text-slate-500 hover:text-red-500 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
)}
</div>
)}
{/* Reaction picker */}
{showReactionPicker && (
<>
<div className="fixed inset-0 z-10" onClick={onCloseReactionPicker} />
<div className={cn(
'absolute top-6 z-20 flex gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-2xl shadow-lg px-2 py-1.5',
isOwn ? 'right-0' : 'left-0',
)}>
{EMOJIS.map(e => (
<button key={e} onClick={() => onReaction(e)} className="text-lg hover:scale-125 transition-transform p-0.5">
{e}
</button>
))}
</div>
</>
)}
</div>
)
}
function RoomSkeleton({ icon, bg, label }: { icon: React.ReactNode; bg: string; label: string }) {
return (
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-100 dark:border-slate-700/50 animate-pulse">

View File

@@ -354,6 +354,12 @@ export const api = {
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
openDirect: (slug: string, otherUserId: string) =>
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>
req<ChatMessage>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`, { text }),
deleteMessage: (slug: string, roomId: string, msgId: string) =>
req<{ ok: boolean; id: string }>('DELETE', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`),
toggleReaction: (slug: string, roomId: string, msgId: string, emoji: string) =>
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}/reactions`, { emoji }),
search: (slug: string, q: string) =>
req<ChatSearchResult[]>('GET', `/api/hotels/${slug}/chat/search?q=${encodeURIComponent(q)}`),
notify: (slug: string, text: string, systemName?: string) =>
@@ -1329,6 +1335,12 @@ export interface ChatRoom {
otherUserId: string | null
}
export interface ChatReaction {
emoji: string
count: number
hasOwn: boolean
}
export interface ChatMessage {
id: string
roomId: string
@@ -1337,9 +1349,12 @@ export interface ChatMessage {
senderRole: string
text: string
createdAt: string
editedAt: string | null
deletedAt: string | null
isSystem: boolean
systemName: string | null
attachmentUrl: string | null
reactions: ChatReaction[]
}
export interface ChatSearchResult {