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:
2026-04-14 13:16:35 +03:00
parent 4de9cfa88e
commit abf1920e6f
4 changed files with 381 additions and 205 deletions

View 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)
);

View File

@@ -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 } }>(