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:
11
backend/migrations/074_chat_edit_delete_reactions.sql
Normal file
11
backend/migrations/074_chat_edit_delete_reactions.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_reactions (
|
||||
message_id UUID NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji VARCHAR(12) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_reactions_message ON chat_reactions(message_id);
|
||||
@@ -3,6 +3,7 @@ import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type RoomParam = { Params: { slug: string; roomId: string } }
|
||||
type MsgParam = { Params: { slug: string; roomId: string; msgId: string } }
|
||||
|
||||
const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string) => {
|
||||
@@ -13,7 +14,6 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
// Ensure a room of given type exists for hotel (idempotent)
|
||||
const ensureRoom = async (hotelId: string, type: string, name: string) => {
|
||||
const { rows: existing } = await db.query(
|
||||
`SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = $2 LIMIT 1`,
|
||||
@@ -26,7 +26,6 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
[hotelId, type, name],
|
||||
)
|
||||
if (rows[0]) return rows[0].id as string
|
||||
// Concurrent insert — fetch again
|
||||
const { rows: r2 } = await db.query(
|
||||
`SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = $2 LIMIT 1`,
|
||||
[hotelId, type],
|
||||
@@ -34,10 +33,37 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return r2[0]?.id as string
|
||||
}
|
||||
|
||||
const ensureGeneralRoom = (hotelId: string) => ensureRoom(hotelId, 'general', 'Общий чат')
|
||||
const ensureGeneralRoom = (hotelId: string) => ensureRoom(hotelId, 'general', 'Общий чат')
|
||||
const ensureNotificationsRoom = (hotelId: string) => ensureRoom(hotelId, 'notifications', 'Уведомления')
|
||||
|
||||
// GET /api/hotels/:slug/chat/rooms — list rooms (general + notifications + directs for current user)
|
||||
// Helper: fetch full message with reactions
|
||||
const fetchMessage = async (msgId: string, userId: string) => {
|
||||
const { rows } = await db.query(
|
||||
`SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at,
|
||||
m.is_system, m.system_name, m.attachment_url, m.edited_at, m.deleted_at,
|
||||
COALESCE(m.system_name, u.name) AS sender_name,
|
||||
COALESCE(u.role, 'system') AS sender_role,
|
||||
COALESCE(
|
||||
(SELECT json_agg(r ORDER BY r.first_at)
|
||||
FROM (SELECT emoji,
|
||||
COUNT(*)::int AS count,
|
||||
bool_or(user_id = $2) AS has_own,
|
||||
MIN(created_at) AS first_at
|
||||
FROM chat_reactions WHERE message_id = m.id
|
||||
GROUP BY emoji) r
|
||||
),
|
||||
'[]'::json
|
||||
) AS reactions
|
||||
FROM chat_messages m
|
||||
LEFT JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.id = $1`,
|
||||
[msgId, userId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
// ── GET rooms ─────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/chat/rooms',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -55,17 +81,19 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const { rows } = await db.query(
|
||||
`SELECT r.id, r.type, r.name,
|
||||
(SELECT COUNT(*) FROM chat_messages m
|
||||
WHERE m.room_id = r.id
|
||||
WHERE m.room_id = r.id AND m.deleted_at IS NULL
|
||||
AND m.created_at > COALESCE(
|
||||
(SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id = $2),
|
||||
'1970-01-01'
|
||||
)
|
||||
AND (m.sender_id != $2 OR m.sender_id IS NULL)
|
||||
) AS unread_count,
|
||||
(SELECT m.text FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT CASE WHEN m.deleted_at IS NOT NULL THEN 'Сообщение удалено'
|
||||
WHEN m.attachment_url IS NOT NULL AND m.text = '' THEN '📷 Фото'
|
||||
ELSE m.text END
|
||||
FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT m.created_at FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT COALESCE(m.system_name, u.name) FROM chat_messages m LEFT JOIN users u ON u.id = m.sender_id WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_sender,
|
||||
-- for direct rooms: get the other user's name
|
||||
(SELECT u.name FROM chat_room_members crm JOIN users u ON u.id = crm.user_id WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_name,
|
||||
(SELECT crm.user_id FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_id
|
||||
FROM chat_rooms r
|
||||
@@ -80,8 +108,9 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
)
|
||||
|
||||
// GET /api/hotels/:slug/chat/rooms/:roomId/messages
|
||||
fastify.get<RoomParam & { Querystring: { before?: string; limit?: string } }>(
|
||||
// ── GET messages ──────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get<RoomParam & { Querystring: { limit?: string } }>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/messages',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
@@ -92,31 +121,44 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const limit = Math.min(Number(request.query.limit ?? 50), 100)
|
||||
const userId = request.user.sub
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at,
|
||||
m.is_system, m.system_name, m.attachment_url,
|
||||
m.is_system, m.system_name, m.attachment_url, m.edited_at, m.deleted_at,
|
||||
COALESCE(m.system_name, u.name) AS sender_name,
|
||||
COALESCE(u.role, 'system') AS sender_role
|
||||
COALESCE(u.role, 'system') AS sender_role,
|
||||
COALESCE(
|
||||
(SELECT json_agg(r ORDER BY r.first_at)
|
||||
FROM (SELECT emoji,
|
||||
COUNT(*)::int AS count,
|
||||
bool_or(user_id = $3) AS has_own,
|
||||
MIN(created_at) AS first_at
|
||||
FROM chat_reactions WHERE message_id = m.id
|
||||
GROUP BY emoji) r
|
||||
),
|
||||
'[]'::json
|
||||
) AS reactions
|
||||
FROM chat_messages m
|
||||
LEFT JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.room_id = $1 AND m.hotel_id = $2
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3`,
|
||||
[roomId, hotelId, limit],
|
||||
LIMIT $4`,
|
||||
[roomId, hotelId, userId, limit],
|
||||
)
|
||||
// Update read status
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO chat_read_status (room_id, user_id, last_read)
|
||||
VALUES ($1, $2, NOW())
|
||||
`INSERT INTO chat_read_status (room_id, user_id, last_read) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`,
|
||||
[roomId, request.user.sub],
|
||||
[roomId, userId],
|
||||
)
|
||||
return rows.reverse() // chronological order
|
||||
return rows.reverse()
|
||||
},
|
||||
)
|
||||
|
||||
// POST /api/hotels/:slug/chat/rooms/:roomId/messages
|
||||
fastify.post<RoomParam & { Body: { text: string } }>(
|
||||
// ── POST message ──────────────────────────────────────────────────────────
|
||||
|
||||
fastify.post<RoomParam & { Body: { text?: string; attachment_url?: string } }>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/messages',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
@@ -126,28 +168,121 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
// Disallow posting to notifications room
|
||||
const { rows: roomRows } = await db.query('SELECT type FROM chat_rooms WHERE id = $1', [roomId])
|
||||
if (roomRows[0]?.type === 'notifications')
|
||||
return reply.code(403).send({ error: 'Cannot post to notifications room' })
|
||||
|
||||
const { text, attachment_url } = request.body as { text?: string; attachment_url?: string }
|
||||
const { text, attachment_url } = request.body
|
||||
if (!text?.trim() && !attachment_url) return reply.code(400).send({ error: 'Text or attachment required' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, room_id, sender_id, text, created_at, attachment_url`,
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[roomId, hotelId, request.user.sub, text?.trim() ?? '', attachment_url ?? null],
|
||||
)
|
||||
const msg = rows[0]
|
||||
const { rows: uRows } = await db.query('SELECT name, role FROM users WHERE id = $1', [request.user.sub])
|
||||
const result = { ...msg, sender_name: uRows[0]?.name, sender_role: uRows[0]?.role, is_system: false, system_name: null }
|
||||
return reply.code(201).send(result)
|
||||
const msg = await fetchMessage(rows[0].id, request.user.sub)
|
||||
return reply.code(201).send(msg)
|
||||
},
|
||||
)
|
||||
|
||||
// POST /api/hotels/:slug/chat/direct/:otherUserId — create/get direct room
|
||||
// ── PATCH message (edit) ──────────────────────────────────────────────────
|
||||
|
||||
fastify.patch<MsgParam & { Body: { text: string } }>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/messages/:msgId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, msgId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT sender_id, is_system, deleted_at FROM chat_messages WHERE id = $1`, [msgId],
|
||||
)
|
||||
const msg = rows[0]
|
||||
if (!msg) return reply.code(404).send({ error: 'Message not found' })
|
||||
if (msg.is_system) return reply.code(403).send({ error: 'Cannot edit system message' })
|
||||
if (msg.deleted_at) return reply.code(400).send({ error: 'Message is deleted' })
|
||||
if (msg.sender_id !== request.user.sub)
|
||||
return reply.code(403).send({ error: 'Can only edit own messages' })
|
||||
|
||||
const { text } = request.body
|
||||
if (!text?.trim()) return reply.code(400).send({ error: 'Text required' })
|
||||
|
||||
await db.query(
|
||||
`UPDATE chat_messages SET text = $1, edited_at = NOW() WHERE id = $2`,
|
||||
[text.trim(), msgId],
|
||||
)
|
||||
const updated = await fetchMessage(msgId, request.user.sub)
|
||||
return updated
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE message (soft) ─────────────────────────────────────────────────
|
||||
|
||||
fastify.delete<MsgParam>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/messages/:msgId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, msgId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT sender_id, is_system FROM chat_messages WHERE id = $1`, [msgId],
|
||||
)
|
||||
const msg = rows[0]
|
||||
if (!msg) return reply.code(404).send({ error: 'Message not found' })
|
||||
if (msg.is_system) return reply.code(403).send({ error: 'Cannot delete system message' })
|
||||
|
||||
const isAdmin = ['super_admin', 'hotel_admin', 'manager'].includes(request.user.role)
|
||||
if (msg.sender_id !== request.user.sub && !isAdmin)
|
||||
return reply.code(403).send({ error: 'Can only delete own messages' })
|
||||
|
||||
await db.query(
|
||||
`UPDATE chat_messages SET deleted_at = NOW(), text = '', attachment_url = NULL WHERE id = $1`,
|
||||
[msgId],
|
||||
)
|
||||
return { ok: true, id: msgId }
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST reaction (toggle) ────────────────────────────────────────────────
|
||||
|
||||
fastify.post<MsgParam & { Body: { emoji: string } }>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/messages/:msgId/reactions',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, msgId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
|
||||
const { emoji } = request.body
|
||||
if (!emoji) return reply.code(400).send({ error: 'Emoji required' })
|
||||
|
||||
const userId = request.user.sub
|
||||
// Toggle: if exists — remove, else — add
|
||||
const { rows } = await db.query(
|
||||
`SELECT 1 FROM chat_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3`,
|
||||
[msgId, userId, emoji],
|
||||
)
|
||||
if (rows[0]) {
|
||||
await db.query(
|
||||
`DELETE FROM chat_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3`,
|
||||
[msgId, userId, emoji],
|
||||
)
|
||||
} else {
|
||||
await db.query(
|
||||
`INSERT INTO chat_reactions (message_id, user_id, emoji) VALUES ($1, $2, $3)`,
|
||||
[msgId, userId, emoji],
|
||||
)
|
||||
}
|
||||
const updated = await fetchMessage(msgId, userId)
|
||||
return updated
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST direct room ──────────────────────────────────────────────────────
|
||||
|
||||
fastify.post<{ Params: { slug: string; otherUserId: string } }>(
|
||||
'/api/hotels/:slug/chat/direct/:otherUserId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -163,15 +298,13 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
`SELECT r.id FROM chat_rooms r
|
||||
JOIN chat_room_members m1 ON m1.room_id = r.id AND m1.user_id = $1
|
||||
JOIN chat_room_members m2 ON m2.room_id = r.id AND m2.user_id = $2
|
||||
WHERE r.hotel_id = $3 AND r.type = 'direct'
|
||||
LIMIT 1`,
|
||||
WHERE r.hotel_id = $3 AND r.type = 'direct' LIMIT 1`,
|
||||
[userId, otherUserId, hotelId],
|
||||
)
|
||||
if (existing[0]) return { room_id: existing[0].id }
|
||||
|
||||
const { rows: [room] } = await db.query(
|
||||
`INSERT INTO chat_rooms (hotel_id, type) VALUES ($1, 'direct') RETURNING id`,
|
||||
[hotelId],
|
||||
`INSERT INTO chat_rooms (hotel_id, type) VALUES ($1, 'direct') RETURNING id`, [hotelId],
|
||||
)
|
||||
await db.query(
|
||||
`INSERT INTO chat_room_members (room_id, user_id) VALUES ($1, $2), ($1, $3)`,
|
||||
@@ -181,7 +314,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
)
|
||||
|
||||
// PATCH /api/hotels/:slug/chat/rooms/:roomId/read — mark as read
|
||||
// ── PATCH mark read ───────────────────────────────────────────────────────
|
||||
|
||||
fastify.patch<RoomParam>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/read',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -196,7 +330,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
)
|
||||
|
||||
// GET /api/hotels/:slug/chat/search?q= — search messages across accessible rooms
|
||||
// ── GET search ────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get<SlugParam & { Querystring: { q?: string } }>(
|
||||
'/api/hotels/:slug/chat/search',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -209,8 +344,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
const q = (request.query.q ?? '').trim()
|
||||
if (!q) return []
|
||||
|
||||
const userId = request.user.sub
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT m.id, m.room_id, m.text, m.created_at,
|
||||
COALESCE(m.system_name, u.name) AS sender_name,
|
||||
@@ -220,20 +355,19 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
FROM chat_messages m
|
||||
JOIN chat_rooms r ON r.id = m.room_id
|
||||
LEFT JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.hotel_id = $1
|
||||
AND m.text ILIKE $3
|
||||
WHERE m.hotel_id = $1 AND m.deleted_at IS NULL AND m.text ILIKE $3
|
||||
AND (r.type IN ('general', 'notifications') OR EXISTS (
|
||||
SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2
|
||||
))
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 30`,
|
||||
ORDER BY m.created_at DESC LIMIT 30`,
|
||||
[hotelId, userId, `%${q}%`],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// POST /api/hotels/:slug/chat/notify — post a system notification (manager/admin only)
|
||||
// ── POST notify ───────────────────────────────────────────────────────────
|
||||
|
||||
fastify.post<SlugParam & { Body: { text: string; system_name?: string } }>(
|
||||
'/api/hotels/:slug/chat/notify',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -253,11 +387,11 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const notifRoomId = await ensureNotificationsRoom(hotelId)
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, is_system, system_name)
|
||||
VALUES ($1, $2, NULL, $3, true, $4)
|
||||
RETURNING id, room_id, text, created_at, is_system, system_name`,
|
||||
VALUES ($1, $2, NULL, $3, true, $4) RETURNING id`,
|
||||
[notifRoomId, hotelId, text.trim(), system_name],
|
||||
)
|
||||
return reply.code(201).send({ ...rows[0], sender_name: system_name, sender_role: 'system' })
|
||||
const msg = await fetchMessage(rows[0].id, request.user.sub)
|
||||
return reply.code(201).send(msg)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user