From a29f58282fa49f8c76593b3d551a127fc8d7e8ae Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 14 Apr 2026 12:31:17 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20edit/delete=20messag?= =?UTF-8?q?es=20+=20emoji=20reactions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../074_chat_edit_delete_reactions.sql | 11 + backend/src/routes/chat.ts | 222 +++++++++++--- src/components/chat/ChatWidget.tsx | 285 +++++++++++++++--- src/lib/api.ts | 15 + 4 files changed, 440 insertions(+), 93 deletions(-) create mode 100644 backend/migrations/074_chat_edit_delete_reactions.sql diff --git a/backend/migrations/074_chat_edit_delete_reactions.sql b/backend/migrations/074_chat_edit_delete_reactions.sql new file mode 100644 index 0000000..4a75138 --- /dev/null +++ b/backend/migrations/074_chat_edit_delete_reactions.sql @@ -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); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 21f0c37..068f286 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -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( '/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( + // โ”€โ”€ GET messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + fastify.get( '/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( + // โ”€โ”€ POST message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + fastify.post( '/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( + '/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( + '/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( + '/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( '/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( '/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( '/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) }, ) } diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index 626bd21..ad4c6bd 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -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(null) const [attachPreview, setAttachPreview] = useState(null) + // Edit / delete / reactions + const [hoveredMsgId, setHoveredMsgId] = useState(null) + const [editingMsgId, setEditingMsgId] = useState(null) + const [editText, setEditText] = useState('') + const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null) + // Search state const [searchQuery, setSearchQuery] = useState('') const [searchUsers, setSearchUsers] = useState([]) @@ -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' ? 'ะฃะฒะตะดะพะผะปะตะฝะธะน ะฟะพะบะฐ ะฝะตั‚' : 'ะะตั‚ ัะพะพะฑั‰ะตะฝะธะน. ะะฐะฟะธัˆะธั‚ะต ะฟะตั€ะฒั‹ะผ!'} ) : ( - messages.map(msg => { - const isSystem = msg.isSystem - const isOwn = !isSystem && msg.senderId === user?.id - return ( -
- {!isOwn && ( - isSystem - ? - : - )} -
- {!isOwn && ( -

- {isSystem ? msg.systemName ?? 'ะกะธัั‚ะตะผะฐ' : msg.senderName.split(' ')[0]} -

- )} -
- {msg.attachmentUrl && ( - - ะฒะปะพะถะตะฝะธะต - - )} - {msg.text && ( -

{msg.text}

- )} -
-

{fmtTime(msg.createdAt)}

-
-
- ) - }) + messages.map(msg => ( + 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) }} + /> + )) )}
@@ -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 ( +
+ {/* Avatar */} + {!isOwn && ( + isSystem + ? + : + )} + +
+ {/* Sender name */} + {!isOwn && !isDeleted && ( +

+ {isSystem ? msg.systemName ?? 'ะกะธัั‚ะตะผะฐ' : msg.senderName.split(' ')[0]} +

+ )} + + {/* Bubble */} + {isDeleted ? ( +

+ ะกะพะพะฑั‰ะตะฝะธะต ัƒะดะฐะปะตะฝะพ +

+ ) : isEditing ? ( +
+