From abf1920e6f21a31593613ea17a0e0b4cc2c59965 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 14 Apr 2026 13:16:35 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20=E2=8B=AE=20room=20m?= =?UTF-8?q?enu,=20typing=20indicator,=20online=20presence,=20read=20receip?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/migrations/075_chat_presence.sql | 17 + backend/src/routes/chat.ts | 79 +++- src/components/chat/ChatWidget.tsx | 481 +++++++++++++---------- src/lib/api.ts | 9 + 4 files changed, 381 insertions(+), 205 deletions(-) create mode 100644 backend/migrations/075_chat_presence.sql diff --git a/backend/migrations/075_chat_presence.sql b/backend/migrations/075_chat_presence.sql new file mode 100644 index 0000000..fa1de7d --- /dev/null +++ b/backend/migrations/075_chat_presence.sql @@ -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) +); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 068f286..3971f71 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -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( + '/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( + '/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( + '/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( + '/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( diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index 4acb57f..ff35542 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -26,11 +26,16 @@ function fmtTime(iso: string) { return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } -function Avatar({ name, size = 28 }: { name: string; size?: number }) { +function Avatar({ name, size = 28, online = false }: { name: string; size?: number; online?: boolean }) { return ( -
- {initials(name)} +
+
+ {initials(name)} +
+ {online && ( + + )}
) } @@ -49,8 +54,7 @@ function playNotifSound() { try { const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext const ctx = new AudioCtx() - const osc = ctx.createOscillator() - const gain = ctx.createGain() + const osc = ctx.createOscillator(), gain = ctx.createGain() osc.connect(gain); gain.connect(ctx.destination) osc.frequency.value = 880 gain.gain.setValueAtTime(0.18, ctx.currentTime) @@ -60,7 +64,7 @@ function playNotifSound() { } catch { /* ignore */ } } -// ── Settings ─────────────────────────────────────────────────────────────── +// ── Persist ──────────────────────────────────────────────────────────────── interface ChatSettings { notifVisible: boolean; soundEnabled: boolean } const SETTINGS_KEY = 'hotelsync-chat-settings' @@ -70,11 +74,10 @@ function loadSettings(): ChatSettings { try { const s = localStorage.getItem(SETTINGS_KEY) if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial } - } catch { /* ignore */ } + } catch { /**/ } return { notifVisible: true, soundEnabled: false } } function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } - function loadPins(): string[] { try { return JSON.parse(localStorage.getItem(PINS_KEY) ?? '[]') as string[] } catch { return [] } } @@ -86,11 +89,9 @@ type CtxMenu = | { type: 'message'; x: number; y: number; msg: ChatMessage } | { type: 'room'; x: number; y: number; room: ChatRoom } -// ── Emojis ───────────────────────────────────────────────────────────────── - const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅'] -// ── Main component ───────────────────────────────────────────────────────── +// ── Main ─────────────────────────────────────────────────────────────────── type View = 'rooms' | 'messages' | 'search' | 'settings' @@ -117,54 +118,54 @@ export function ChatWidget() { // Context menu const [ctxMenu, setCtxMenu] = useState(null) + // Typing & presence + const [typingNames, setTypingNames] = useState([]) + const [onlineIds, setOnlineIds] = useState([]) + // Search - const [searchQuery, setSearchQuery] = useState('') - const [searchUsers, setSearchUsers] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [searchUsers, setSearchUsers] = useState([]) const [searchResults, setSearchResults] = useState([]) - const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people') + const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people') const [loadingSearch, setLoadingSearch] = useState(false) - const [allUsers, setAllUsers] = useState([]) + const [allUsers, setAllUsers] = useState([]) // Settings + pins const [settings, setSettings] = useState(loadSettings) const [pinnedIds, setPinnedIds] = useState(loadPins) - const messagesEndRef = useRef(null) - const pollRef = useRef | null>(null) - const searchInputRef = useRef(null) - const fileInputRef = useRef(null) - const prevUnreadRef = useRef(0) + const messagesEndRef = useRef(null) + const pollRef = useRef | null>(null) + const searchInputRef = useRef(null) + const fileInputRef = useRef(null) + const prevUnreadRef = useRef(0) const prevMsgCountRef = useRef(0) + const typingTimerRef = useRef | null>(null) + const isTypingRef = useRef(false) const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) - // ── Sorted rooms (pinned first, then by last message) ──────────────────── - const visibleRooms = [...rooms] .filter(r => !(r.type === 'notifications' && !settings.notifVisible)) - .sort((a, b) => { - const aPin = pinnedIds.includes(a.id) ? 0 : 1 - const bPin = pinnedIds.includes(b.id) ? 0 : 1 - return aPin - bPin - }) + .sort((a, b) => (pinnedIds.includes(a.id) ? 0 : 1) - (pinnedIds.includes(b.id) ? 0 : 1)) - // ── Loaders ────────────────────────────────────────────────────────────── + // ── Effects ─────────────────────────────────────────────────────────────── const loadRooms = useCallback(async () => { if (!slug) return try { const data = await api.chat.listRooms(slug) setRooms(data) - // Sound on new unread when widget is closed if (settings.soundEnabled && !open) { - const newUnread = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) - if (newUnread > prevUnreadRef.current) playNotifSound() - prevUnreadRef.current = newUnread + const n = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) + if (n > prevUnreadRef.current) playNotifSound() + prevUnreadRef.current = n } - } catch { /* ignore */ } + } catch { /**/ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [slug, settings.soundEnabled, open]) + // Poll rooms useEffect(() => { if (!open || !slug) return setLoadingRooms(true) @@ -173,34 +174,52 @@ export function ChatWidget() { return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [open, slug, loadRooms]) + // Presence heartbeat + useEffect(() => { + if (!open || !slug) return + api.chat.setPresence(slug).catch(() => {/**/}) + api.chat.getPresence(slug).then(setOnlineIds).catch(() => {/**/}) + const t = setInterval(async () => { + api.chat.setPresence(slug).catch(() => {/**/}) + api.chat.getPresence(slug).then(setOnlineIds).catch(() => {/**/}) + }, 30_000) + return () => clearInterval(t) + }, [open, slug]) + // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) - // Poll messages + // Poll messages + typing useEffect(() => { if (view !== 'messages' || !activeRoom) return const interval = setInterval(async () => { try { - const msgs = await api.chat.getMessages(slug, activeRoom.id) - // Sound on new messages in active room + const [msgs, typing] = await Promise.all([ + api.chat.getMessages(slug, activeRoom.id), + api.chat.getTyping(slug, activeRoom.id), + ]) if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) { - const lastNew = msgs[msgs.length - 1] - if (lastNew.senderId !== user?.id) playNotifSound() + const last = msgs[msgs.length - 1] + if (last.senderId !== user?.id) playNotifSound() } prevMsgCountRef.current = msgs.length setMessages(msgs) - } catch { /* ignore */ } - }, 3000) + setTypingNames(typing) + } catch { /**/ } + }, 2000) return () => clearInterval(interval) }, [view, activeRoom, slug, settings.soundEnabled, user?.id]) - // Load users for search + // Clear typing names when leaving room + useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view]) + + // Search users useEffect(() => { if (view !== 'search' || !slug) return - api.users.list(slug).then(setAllUsers).catch(() => { /* ignore */ }) + api.users.list(slug).then(setAllUsers).catch(() => {/**/}) }, [view, slug]) - // Search + // Search effect useEffect(() => { if (view !== 'search') return const q = searchQuery.trim() @@ -210,7 +229,7 @@ export function ChatWidget() { const timer = setTimeout(async () => { if (searchTab !== 'messages') return setLoadingSearch(true) - try { setSearchResults(await api.chat.search(slug, q)) } catch { /* ignore */ } + try { setSearchResults(await api.chat.search(slug, q)) } catch { /**/ } finally { setLoadingSearch(false) } }, 400) return () => clearTimeout(timer) @@ -220,29 +239,43 @@ export function ChatWidget() { if (view === 'search') setTimeout(() => searchInputRef.current?.focus(), 50) }, [view]) - // Close context menu on outside click + // Close ctx menu on outside click useEffect(() => { if (!ctxMenu) return - const handler = () => setCtxMenu(null) - window.addEventListener('click', handler) - window.addEventListener('contextmenu', handler) - return () => { window.removeEventListener('click', handler); window.removeEventListener('contextmenu', handler) } + const h = () => setCtxMenu(null) + window.addEventListener('click', h) + return () => window.removeEventListener('click', h) }, [ctxMenu]) + // ── Typing helpers ──────────────────────────────────────────────────────── + + const sendTyping = useCallback((typing: boolean) => { + if (!activeRoom || !slug) return + if (typing === isTypingRef.current) return + isTypingRef.current = typing + api.chat.setTyping(slug, activeRoom.id, typing).catch(() => {/**/}) + }, [activeRoom, slug]) + + const handleTextChange = (val: string) => { + setText(val) + if (!val.trim()) { sendTyping(false); return } + sendTyping(true) + if (typingTimerRef.current) clearTimeout(typingTimerRef.current) + typingTimerRef.current = setTimeout(() => sendTyping(false), 4000) + } + // ── Actions ─────────────────────────────────────────────────────────────── const openRoom = async (room: ChatRoom) => { - setActiveRoom(room) - setView('messages') - setLoadingMsgs(true) - prevMsgCountRef.current = 0 + setActiveRoom(room); setView('messages'); setLoadingMsgs(true) + prevMsgCountRef.current = 0; isTypingRef.current = false try { const msgs = await api.chat.getMessages(slug, room.id) prevMsgCountRef.current = msgs.length setMessages(msgs) await api.chat.markRead(slug, room.id) setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r)) - } catch { /* ignore */ } + } catch { /**/ } finally { setLoadingMsgs(false) } } @@ -250,9 +283,9 @@ export function ChatWidget() { try { const { roomId } = await api.chat.openDirect(slug, targetUser.id) await loadRooms() - await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserId: targetUser.id }) + await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserId: targetUser.id, otherUserLastRead: null }) setSearchQuery('') - } catch { /* ignore */ } + } catch { /**/ } } const openSearchResult = async (result: ChatSearchResult) => { @@ -261,25 +294,22 @@ export function ChatWidget() { } const handleFileSelect = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return + const file = e.target.files?.[0]; if (!file) return setAttachment(file) const reader = new FileReader() reader.onload = ev => setAttachPreview(ev.target?.result as string) - reader.readAsDataURL(file) - e.target.value = '' + reader.readAsDataURL(file); e.target.value = '' } - const removeAttachment = () => { setAttachment(null); setAttachPreview(null) } - const sendMessage = async () => { if ((!text.trim() && !attachment) || !activeRoom || sending) return const t = text.trim(), file = attachment setText(''); setAttachment(null); setAttachPreview(null); setSending(true) + sendTyping(false) try { - let attachmentUrl: string | undefined - if (file) { const { url } = await api.chat.uploadImage(file); attachmentUrl = url } - const msg = await api.chat.sendMessage(slug, activeRoom.id, t, attachmentUrl) + let url: string | undefined + if (file) { url = (await api.chat.uploadImage(file)).url } + const msg = await api.chat.sendMessage(slug, activeRoom.id, t, url) setMessages(prev => { prevMsgCountRef.current = prev.length + 1; return [...prev, msg] }) } catch { setText(t) @@ -297,27 +327,24 @@ export function ChatWidget() { 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 */ } + } catch { /**/ } setEditingMsgId(null) } - const cancelEdit = () => setEditingMsgId(null) const deleteMsg = async (msg: ChatMessage) => { - if (!activeRoom) return - setCtxMenu(null) + if (!activeRoom) return; setCtxMenu(null) 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 */ } + } catch { /**/ } } const toggleReaction = async (msg: ChatMessage, emoji: string) => { - if (!activeRoom) return - setCtxMenu(null) + if (!activeRoom) return; setCtxMenu(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 */ } + } catch { /**/ } } const togglePin = (roomId: string) => { @@ -331,7 +358,10 @@ export function ChatWidget() { } const goBack = () => { - if (view === 'messages') { setView('rooms'); setActiveRoom(null); setEditingMsgId(null) } + if (view === 'messages') { + sendTyping(false) + setView('rooms'); setActiveRoom(null); setEditingMsgId(null); setTypingNames([]) + } else if (view === 'search') { setView('rooms'); setSearchQuery('') } else if (view === 'settings') setView('rooms') } @@ -339,15 +369,13 @@ export function ChatWidget() { const roomDisplayName = (room: ChatRoom) => room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат' - const onMsgContextMenu = (e: React.MouseEvent, msg: ChatMessage) => { - if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return - e.preventDefault() - setCtxMenu({ type: 'message', x: e.clientX, y: e.clientY, msg }) - } + const isOtherOnline = (room: ChatRoom) => + room.type === 'direct' && room.otherUserId ? onlineIds.includes(room.otherUserId) : false - const onRoomContextMenu = (e: React.MouseEvent, room: ChatRoom) => { - e.preventDefault() - setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) + // Read receipt: is message read by other side? + const isRead = (msg: ChatMessage) => { + if (!activeRoom || activeRoom.type !== 'direct' || !activeRoom.otherUserLastRead) return false + return new Date(msg.createdAt) <= new Date(activeRoom.otherUserLastRead) } if (!slug) return null @@ -356,15 +384,9 @@ export function ChatWidget() { return ( <> - {/* Floating button */} - - {/* Chat panel */} + {/* Panel */} {open && ( -
+
+ {/* Header */}
{view !== 'rooms' && ( @@ -391,14 +407,21 @@ export function ChatWidget() { )}
-

- {view === 'rooms' && 'Чат сотрудников'} - {view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')} - {view === 'search' && 'Поиск'} - {view === 'settings' && 'Настройки чата'} -

- {view === 'rooms' && visibleRooms.length > 0 && ( -

{visibleRooms.length} чатов

+
+

+ {view === 'rooms' && 'Чат сотрудников'} + {view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')} + {view === 'search' && 'Поиск'} + {view === 'settings' && 'Настройки чата'} +

+ {/* Online dot in messages header */} + {view === 'messages' && activeRoom && isOtherOnline(activeRoom) && ( + + )} +
+ {view === 'rooms' && visibleRooms.length > 0 &&

{visibleRooms.length} чатов

} + {view === 'messages' && activeRoom?.type === 'direct' && isOtherOnline(activeRoom) && ( +

в сети

)} {view === 'messages' && activeRoom?.type === 'notifications' && (

Только чтение

@@ -406,15 +429,9 @@ export function ChatWidget() {
{view === 'rooms' && (
- - - + + +
)}
@@ -429,48 +446,14 @@ export function ChatWidget() { ) : ( visibleRooms.map(room => ( - + onMenuClick={e => { e.stopPropagation(); setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) }} + /> )) )}
@@ -488,24 +471,25 @@ export function ChatWidget() { {searchQuery && }
-
+
{(['people', 'messages'] as const).map(tab => ( ))}
-
+
{searchTab === 'people' && ( !searchQuery ? allUsers.length === 0 ?
Загрузка...
- : allUsers.filter(u => u.id !== user?.id).map(u => openDirect(u)} />) + : allUsers.filter(u => u.id !== user?.id).map(u => ( + openDirect(u)} /> + )) : searchUsers.length === 0 ?
Никого не найдено
- : searchUsers.map(u => openDirect(u)} />) + : searchUsers.map(u => openDirect(u)} />) )} {searchTab === 'messages' && ( !searchQuery @@ -534,25 +518,19 @@ export function ChatWidget() { {view === 'settings' && (
- : } - label="Показывать канал уведомлений" - checked={settings.notifVisible} - onChange={v => updateSettings({ notifVisible: v })} - /> + : } + label="Показывать канал уведомлений" checked={settings.notifVisible} + onChange={v => updateSettings({ notifVisible: v })} /> - : } - label="Звуковое уведомление" - checked={settings.soundEnabled} - onChange={v => updateSettings({ soundEnabled: v })} - /> + : } + label="Звуковое уведомление" checked={settings.soundEnabled} + onChange={v => updateSettings({ soundEnabled: v })} /> -

+

Правый клик на сообщении — реакции, редактирование, удаление.
- Правый клик на чате — закрепить / открепить сверху. + Кнопка ⋮ на чате — закрепить / открепить сверху.

@@ -576,35 +554,53 @@ export function ChatWidget() { isOwn={!msg.isSystem && msg.senderId === user?.id} isEditing={editingMsgId === msg.id} editText={editText} - onContextMenu={e => onMsgContextMenu(e, msg)} + isRead={isRead(msg)} + onContextMenu={e => { + if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return + e.preventDefault() + setCtxMenu({ type: 'message', x: e.clientX, y: e.clientY, msg }) + }} onEditTextChange={setEditText} onSaveEdit={() => saveEdit(msg)} - onCancelEdit={cancelEdit} + onCancelEdit={() => setEditingMsgId(null)} onReaction={emoji => toggleReaction(msg, emoji)} /> )) )} + + {/* Typing indicator */} + {typingNames.length > 0 && ( +
+ + + {typingNames.length === 1 + ? `${typingNames[0].split(' ')[0]} печатает...` + : `${typingNames.map(n => n.split(' ')[0]).join(', ')} печатают...`} + +
+ )} +
- {/* Input */} {activeRoom?.type !== 'notifications' && (
{attachPreview && (
превью -
)}
- -