feat: chat — ⋮ room menu, typing indicator, online presence, read receipts
- RoomRow: ⋮ button (hover) for pin/unpin context menu - MessageBubble: right-click context menu with emoji reactions + edit/delete - Typing indicator: debounced setTyping, animated TypingDots, polling getTyping every 2s - Online presence: heartbeat setPresence every 30s, green dot on avatars/header - Read receipts: ✓/✓✓ for own messages in direct rooms via otherUserLastRead - Migration 075: chat_presence + chat_typing tables - Context menus clamped to viewport bounds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
17
backend/migrations/075_chat_presence.sql
Normal file
17
backend/migrations/075_chat_presence.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Online presence
|
||||
CREATE TABLE IF NOT EXISTS chat_presence (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, hotel_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_presence_hotel ON chat_presence(hotel_id, last_seen);
|
||||
|
||||
-- Typing indicator (expires after 5s — queried with WHERE typing_at > NOW() - interval '5s')
|
||||
CREATE TABLE IF NOT EXISTS chat_typing (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
room_id UUID NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
typing_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
@@ -95,7 +95,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
(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,
|
||||
(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
|
||||
(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,
|
||||
(SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id != $2 LIMIT 1) AS other_user_last_read
|
||||
FROM chat_rooms r
|
||||
WHERE r.hotel_id = $1
|
||||
AND (r.type IN ('general', 'notifications') OR EXISTS (
|
||||
@@ -366,6 +367,82 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST presence (heartbeat) ─────────────────────────────────────────────
|
||||
|
||||
fastify.post<SlugParam>(
|
||||
'/api/hotels/:slug/chat/presence',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
await db.query(
|
||||
`INSERT INTO chat_presence (user_id, hotel_id, last_seen) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id, hotel_id) DO UPDATE SET last_seen = NOW()`,
|
||||
[request.user.sub, hotelId],
|
||||
)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET online users ──────────────────────────────────────────────────────
|
||||
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/chat/presence',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
const { rows } = await db.query(
|
||||
`SELECT user_id FROM chat_presence WHERE hotel_id = $1 AND last_seen > NOW() - INTERVAL '90 seconds'`,
|
||||
[hotelId],
|
||||
)
|
||||
return rows.map((r: { user_id: string }) => r.user_id)
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST typing ───────────────────────────────────────────────────────────
|
||||
|
||||
fastify.post<MsgParam & { Body: { typing: boolean } }>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/typing',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { roomId } = request.params
|
||||
const { typing } = request.body
|
||||
const { rows: uRows } = await db.query('SELECT name FROM users WHERE id = $1', [request.user.sub])
|
||||
const name = uRows[0]?.name ?? 'Кто-то'
|
||||
if (typing) {
|
||||
await db.query(
|
||||
`INSERT INTO chat_typing (user_id, room_id, name, typing_at) VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (user_id, room_id) DO UPDATE SET typing_at = NOW(), name = $3`,
|
||||
[request.user.sub, roomId, name],
|
||||
)
|
||||
} else {
|
||||
await db.query('DELETE FROM chat_typing WHERE user_id = $1 AND room_id = $2', [request.user.sub, roomId])
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET typing ────────────────────────────────────────────────────────────
|
||||
|
||||
fastify.get<RoomParam>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/typing',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { roomId } = request.params
|
||||
const userId = request.user.sub
|
||||
const { rows } = await db.query(
|
||||
`SELECT name FROM chat_typing WHERE room_id = $1 AND user_id != $2 AND typing_at > NOW() - INTERVAL '5 seconds'`,
|
||||
[roomId, userId],
|
||||
)
|
||||
return rows.map((r: { name: string }) => r.name)
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST notify ───────────────────────────────────────────────────────────
|
||||
|
||||
fastify.post<SlugParam & { Body: { text: string; system_name?: string } }>(
|
||||
|
||||
Reference in New Issue
Block a user