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 } }>(
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ width: size, height: size, background: avatarColor(name), fontSize: size * 0.38 }}
|
||||
className="rounded-full flex items-center justify-center text-white font-semibold shrink-0">
|
||||
{initials(name)}
|
||||
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||
<div style={{ width: size, height: size, background: avatarColor(name), fontSize: size * 0.38 }}
|
||||
className="rounded-full flex items-center justify-center text-white font-semibold">
|
||||
{initials(name)}
|
||||
</div>
|
||||
{online && (
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-white dark:border-slate-800" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ChatSettings> }
|
||||
} 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<CtxMenu | null>(null)
|
||||
|
||||
// Typing & presence
|
||||
const [typingNames, setTypingNames] = useState<string[]>([])
|
||||
const [onlineIds, setOnlineIds] = useState<string[]>([])
|
||||
|
||||
// Search
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchUsers, setSearchUsers] = useState<User[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchUsers, setSearchUsers] = useState<User[]>([])
|
||||
const [searchResults, setSearchResults] = useState<ChatSearchResult[]>([])
|
||||
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
|
||||
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
|
||||
const [loadingSearch, setLoadingSearch] = useState(false)
|
||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||
|
||||
// Settings + pins
|
||||
const [settings, setSettings] = useState<ChatSettings>(loadSettings)
|
||||
const [pinnedIds, setPinnedIds] = useState<string[]>(loadPins)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const prevUnreadRef = useRef<number>(0)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const prevUnreadRef = useRef<number>(0)
|
||||
const prevMsgCountRef = useRef<number>(0)
|
||||
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
|
||||
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 */}
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className={cn(
|
||||
'fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg',
|
||||
'bg-brand-600 hover:bg-brand-700 text-white transition-all flex items-center justify-center',
|
||||
open && 'scale-90',
|
||||
)}
|
||||
>
|
||||
{/* Float button */}
|
||||
<button onClick={() => setOpen(v => !v)}
|
||||
className={cn('fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg bg-brand-600 hover:bg-brand-700 text-white transition-all flex items-center justify-center', open && 'scale-90')}>
|
||||
{open ? <X size={22} /> : <MessageSquare size={22} />}
|
||||
{!open && totalUnread > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center">
|
||||
@@ -373,16 +395,10 @@ export function ChatWidget() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Chat panel */}
|
||||
{/* Panel */}
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-24 right-6 z-50',
|
||||
'w-80 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl',
|
||||
'border border-slate-200 dark:border-slate-700 flex flex-col overflow-hidden',
|
||||
)}
|
||||
style={{ height: 520 }}
|
||||
>
|
||||
<div className="fixed bottom-24 right-6 z-50 w-80 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 flex flex-col overflow-hidden" style={{ height: 520 }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-3 border-b border-slate-200 dark:border-slate-700 bg-brand-600 text-white rounded-t-2xl shrink-0">
|
||||
{view !== 'rooms' && (
|
||||
@@ -391,14 +407,21 @@ export function ChatWidget() {
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm truncate">
|
||||
{view === 'rooms' && 'Чат сотрудников'}
|
||||
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
|
||||
{view === 'search' && 'Поиск'}
|
||||
{view === 'settings' && 'Настройки чата'}
|
||||
</p>
|
||||
{view === 'rooms' && visibleRooms.length > 0 && (
|
||||
<p className="text-xs text-white/70">{visibleRooms.length} чатов</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-sm truncate">
|
||||
{view === 'rooms' && 'Чат сотрудников'}
|
||||
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
|
||||
{view === 'search' && 'Поиск'}
|
||||
{view === 'settings' && 'Настройки чата'}
|
||||
</p>
|
||||
{/* Online dot in messages header */}
|
||||
{view === 'messages' && activeRoom && isOtherOnline(activeRoom) && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-400 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{view === 'rooms' && visibleRooms.length > 0 && <p className="text-xs text-white/70">{visibleRooms.length} чатов</p>}
|
||||
{view === 'messages' && activeRoom?.type === 'direct' && isOtherOnline(activeRoom) && (
|
||||
<p className="text-xs text-green-300">в сети</p>
|
||||
)}
|
||||
{view === 'messages' && activeRoom?.type === 'notifications' && (
|
||||
<p className="text-xs text-white/70">Только чтение</p>
|
||||
@@ -406,15 +429,9 @@ export function ChatWidget() {
|
||||
</div>
|
||||
{view === 'rooms' && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => setView('search')} title="Поиск / Новый чат" className="p-1.5 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<Search size={15} />
|
||||
</button>
|
||||
<button onClick={() => setView('search')} title="Написать" className="p-1.5 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<PenSquare size={15} />
|
||||
</button>
|
||||
<button onClick={() => setView('settings')} title="Настройки" className="p-1.5 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Search size={15} /></button>
|
||||
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><PenSquare size={15} /></button>
|
||||
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Settings size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -429,48 +446,14 @@ export function ChatWidget() {
|
||||
</>
|
||||
) : (
|
||||
visibleRooms.map(room => (
|
||||
<button
|
||||
<RoomRow
|
||||
key={room.id}
|
||||
room={room}
|
||||
isPinned={pinnedIds.includes(room.id)}
|
||||
isOnline={isOtherOnline(room)}
|
||||
onClick={() => openRoom(room)}
|
||||
onContextMenu={e => onRoomContextMenu(e, room)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
{room.type === 'general' ? (
|
||||
<div className="w-9 h-9 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<Users size={16} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
) : room.type === 'notifications' ? (
|
||||
<div className="w-9 h-9 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<Bell size={16} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar name={room.otherUserName ?? '?'} size={36} />
|
||||
)}
|
||||
{Number(room.unreadCount) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{room.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{pinnedIds.includes(room.id) && <Pin size={10} className="text-brand-500 shrink-0" />}
|
||||
<p className={cn('text-sm font-medium truncate flex-1', Number(room.unreadCount) > 0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
|
||||
{roomDisplayName(room)}
|
||||
</p>
|
||||
{room.lastMessageAt && (
|
||||
<span className="text-[10px] text-slate-400 shrink-0">{fmtTime(room.lastMessageAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{room.lastMessage && (
|
||||
<p className={cn('text-xs truncate mt-0.5', Number(room.unreadCount) > 0 ? 'text-slate-600 dark:text-slate-300 font-medium' : 'text-slate-400 dark:text-slate-500')}>
|
||||
{room.lastSender && room.type === 'general' ? `${room.lastSender.split(' ')[0]}: ` : ''}
|
||||
{room.lastMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
onMenuClick={e => { e.stopPropagation(); setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room }) }}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -488,24 +471,25 @@ export function ChatWidget() {
|
||||
{searchQuery && <button onClick={() => setSearchQuery('')} className="text-slate-400 hover:text-slate-600"><X size={13} /></button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex px-3 gap-1 shrink-0">
|
||||
<div className="flex px-3 gap-1 shrink-0 mb-1">
|
||||
{(['people', 'messages'] as const).map(tab => (
|
||||
<button key={tab} onClick={() => setSearchTab(tab)}
|
||||
className={cn('flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors',
|
||||
searchTab === tab ? 'bg-brand-600 text-white' : 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700')}>
|
||||
className={cn('flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors', searchTab === tab ? 'bg-brand-600 text-white' : 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700')}>
|
||||
{tab === 'people' ? 'Люди' : 'Сообщения'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto mt-2">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{searchTab === 'people' && (
|
||||
!searchQuery
|
||||
? allUsers.length === 0
|
||||
? <div className="text-center py-6 text-xs text-slate-400">Загрузка...</div>
|
||||
: allUsers.filter(u => u.id !== user?.id).map(u => <UserRow key={u.id} user={u} onClick={() => openDirect(u)} />)
|
||||
: allUsers.filter(u => u.id !== user?.id).map(u => (
|
||||
<UserRow key={u.id} user={u} online={onlineIds.includes(u.id)} onClick={() => openDirect(u)} />
|
||||
))
|
||||
: searchUsers.length === 0
|
||||
? <div className="text-center py-6 text-xs text-slate-400">Никого не найдено</div>
|
||||
: searchUsers.map(u => <UserRow key={u.id} user={u} onClick={() => openDirect(u)} />)
|
||||
: searchUsers.map(u => <UserRow key={u.id} user={u} online={onlineIds.includes(u.id)} onClick={() => openDirect(u)} />)
|
||||
)}
|
||||
{searchTab === 'messages' && (
|
||||
!searchQuery
|
||||
@@ -534,25 +518,19 @@ export function ChatWidget() {
|
||||
{view === 'settings' && (
|
||||
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
||||
<SettingsSection title="Канал уведомлений">
|
||||
<ToggleRow
|
||||
icon={settings.notifVisible ? <Bell size={15} /> : <BellOff size={15} />}
|
||||
label="Показывать канал уведомлений"
|
||||
checked={settings.notifVisible}
|
||||
onChange={v => updateSettings({ notifVisible: v })}
|
||||
/>
|
||||
<ToggleRow icon={settings.notifVisible ? <Bell size={15} /> : <BellOff size={15} />}
|
||||
label="Показывать канал уведомлений" checked={settings.notifVisible}
|
||||
onChange={v => updateSettings({ notifVisible: v })} />
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Звук">
|
||||
<ToggleRow
|
||||
icon={settings.soundEnabled ? <Volume2 size={15} /> : <VolumeX size={15} />}
|
||||
label="Звуковое уведомление"
|
||||
checked={settings.soundEnabled}
|
||||
onChange={v => updateSettings({ soundEnabled: v })}
|
||||
/>
|
||||
<ToggleRow icon={settings.soundEnabled ? <Volume2 size={15} /> : <VolumeX size={15} />}
|
||||
label="Звуковое уведомление" checked={settings.soundEnabled}
|
||||
onChange={v => updateSettings({ soundEnabled: v })} />
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Подсказка">
|
||||
<p className="text-xs text-slate-500 leading-relaxed px-4">
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Правый клик на сообщении — реакции, редактирование, удаление.<br />
|
||||
Правый клик на чате — закрепить / открепить сверху.
|
||||
Кнопка ⋮ на чате — закрепить / открепить сверху.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
@@ -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 && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<TypingDots />
|
||||
<span className="text-xs text-slate-400">
|
||||
{typingNames.length === 1
|
||||
? `${typingNames[0].split(' ')[0]} печатает...`
|
||||
: `${typingNames.map(n => n.split(' ')[0]).join(', ')} печатают...`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{activeRoom?.type !== 'notifications' && (
|
||||
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
{attachPreview && (
|
||||
<div className="relative inline-block mb-2">
|
||||
<img src={attachPreview} alt="превью" className="h-16 rounded-lg object-cover border border-slate-200 dark:border-slate-600" />
|
||||
<button onClick={removeAttachment} className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-slate-700 text-white flex items-center justify-center hover:bg-red-500 transition-colors">
|
||||
<button onClick={() => { setAttachment(null); setAttachPreview(null) }}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-slate-700 text-white flex items-center justify-center hover:bg-red-500 transition-colors">
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-1.5">
|
||||
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileSelect} />
|
||||
<button onClick={() => fileInputRef.current?.click()} title="Прикрепить фото"
|
||||
<button onClick={() => fileInputRef.current?.click()}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-brand-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors shrink-0">
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<textarea value={text} onChange={e => setText(e.target.value)} onKeyDown={handleKeyDown}
|
||||
<textarea value={text} onChange={e => handleTextChange(e.target.value)} onKeyDown={handleKeyDown}
|
||||
placeholder="Сообщение..." rows={1}
|
||||
className="flex-1 resize-none rounded-xl px-3 py-2 text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none max-h-24 overflow-y-auto" />
|
||||
<button onClick={() => void sendMessage()} disabled={(!text.trim() && !attachment) || sending}
|
||||
@@ -642,18 +638,82 @@ export function ChatWidget() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────
|
||||
// ── RoomRow ────────────────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ msg, isOwn, isEditing, editText, onContextMenu, onEditTextChange, onSaveEdit, onCancelEdit, onReaction }: {
|
||||
msg: ChatMessage; isOwn: boolean; isEditing: boolean; editText: string
|
||||
function RoomRow({ room, isPinned, isOnline, onClick, onMenuClick }: {
|
||||
room: ChatRoom; isPinned: boolean; isOnline: boolean
|
||||
onClick: () => void; onMenuClick: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 cursor-pointer"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="relative shrink-0">
|
||||
{room.type === 'general' ? (
|
||||
<div className="w-9 h-9 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<Users size={16} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
) : room.type === 'notifications' ? (
|
||||
<div className="w-9 h-9 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<Bell size={16} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar name={room.otherUserName ?? '?'} size={36} online={isOnline} />
|
||||
)}
|
||||
{Number(room.unreadCount) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{room.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{isPinned && <Pin size={10} className="text-brand-500 shrink-0" />}
|
||||
<p className={cn('text-sm font-medium truncate flex-1', Number(room.unreadCount) > 0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
|
||||
{room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName}
|
||||
</p>
|
||||
{!hovered && room.lastMessageAt && (
|
||||
<span className="text-[10px] text-slate-400 shrink-0">{fmtTime(room.lastMessageAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{room.lastMessage && (
|
||||
<p className={cn('text-xs truncate mt-0.5', Number(room.unreadCount) > 0 ? 'text-slate-600 dark:text-slate-300 font-medium' : 'text-slate-400 dark:text-slate-500')}>
|
||||
{room.lastSender && room.type === 'general' ? `${room.lastSender.split(' ')[0]}: ` : ''}{room.lastMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ⋮ button — appears on hover */}
|
||||
{hovered && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-500 transition-colors text-base leading-none"
|
||||
title="Действия"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── MessageBubble ──────────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu, onEditTextChange, onSaveEdit, onCancelEdit, onReaction }: {
|
||||
msg: ChatMessage; isOwn: boolean; isEditing: boolean; editText: string; isRead: boolean
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onEditTextChange: (v: string) => void
|
||||
onSaveEdit: () => void; onCancelEdit: () => void
|
||||
onReaction: (emoji: string) => void
|
||||
}) {
|
||||
const isDeleted = !!msg.deletedAt
|
||||
const isSystem = msg.isSystem
|
||||
|
||||
const isDeleted = !!msg.deletedAt, isSystem = msg.isSystem
|
||||
return (
|
||||
<div className={cn('flex gap-2', isOwn && 'flex-row-reverse')} onContextMenu={onContextMenu}>
|
||||
{!isOwn && (isSystem ? <SystemAvatar size={24} /> : <Avatar name={msg.senderName} size={24} />)}
|
||||
@@ -695,13 +755,20 @@ function MessageBubble({ msg, isOwn, isEditing, editText, onContextMenu, onEditT
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta: time + edited + read receipt */}
|
||||
{!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>}
|
||||
{isOwn && (
|
||||
<span className={cn('text-[11px] leading-none', isRead ? 'text-brand-400' : 'text-slate-300 dark:text-slate-600')}>
|
||||
{isRead ? '✓✓' : '✓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{!isDeleted && msg.reactions?.length > 0 && (
|
||||
<div className={cn('flex flex-wrap gap-1 mt-1', isOwn && 'justify-end')}>
|
||||
{(msg.reactions as ChatReaction[]).map(r => (
|
||||
@@ -720,34 +787,28 @@ function MessageBubble({ msg, isOwn, isEditing, editText, onContextMenu, onEditT
|
||||
)
|
||||
}
|
||||
|
||||
// ── ContextMenu ────────────────────────────────────────────────────────────
|
||||
|
||||
function ContextMenu({ menu, currentUserId, pinnedIds, onReaction, onEdit, onDelete, onTogglePin, onClose }: {
|
||||
menu: CtxMenu; currentUserId: string; pinnedIds: string[]
|
||||
onReaction: (emoji: string) => void; onEdit: () => void; onDelete: () => void
|
||||
onTogglePin: () => void; onClose: () => void
|
||||
}) {
|
||||
// Clamp to viewport
|
||||
const menuW = 180, menuH = menu.type === 'message' ? 160 : 60
|
||||
const menuW = 182, menuH = menu.type === 'message' ? 160 : 56
|
||||
const x = Math.min(menu.x, window.innerWidth - menuW - 8)
|
||||
const y = Math.min(menu.y, window.innerHeight - menuH - 8)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', top: y, left: x, zIndex: 9999, minWidth: menuW }}
|
||||
<div style={{ position: 'fixed', top: y, left: x, zIndex: 9999, minWidth: menuW }}
|
||||
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-xl shadow-xl py-1"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{menu.type === 'message' && (
|
||||
<>
|
||||
{/* Emoji row */}
|
||||
<div className="flex justify-around px-2 py-1.5 border-b border-slate-100 dark:border-slate-700">
|
||||
{EMOJIS.map(e => (
|
||||
<button key={e} onClick={() => { onReaction(e); onClose() }}
|
||||
className="text-base hover:scale-125 transition-transform leading-none p-0.5">
|
||||
{e}
|
||||
</button>
|
||||
className="text-base hover:scale-125 transition-transform leading-none p-0.5">{e}</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
{menu.msg.senderId === currentUserId && (
|
||||
<button onClick={() => { onEdit(); onClose() }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 flex items-center gap-2">
|
||||
@@ -775,14 +836,26 @@ function ContextMenu({ menu, currentUserId, pinnedIds, onReaction, onEdit, onDel
|
||||
)
|
||||
}
|
||||
|
||||
function UserRow({ user, onClick }: { user: User; onClick: () => void }) {
|
||||
// ── Misc sub-components ───────────────────────────────────────────────────
|
||||
|
||||
function TypingDots() {
|
||||
return (
|
||||
<div className="flex gap-0.5 items-center">
|
||||
{[0, 1, 2].map(i => (
|
||||
<span key={i} className="w-1.5 h-1.5 rounded-full bg-slate-400 animate-bounce" style={{ animationDelay: `${i * 0.15}s` }} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserRow({ user, online, onClick }: { user: User; online: boolean; onClick: () => void }) {
|
||||
const roleLabels: Record<string, string> = {
|
||||
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||||
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
||||
}
|
||||
return (
|
||||
<button onClick={onClick} className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left">
|
||||
<Avatar name={user.name} size={32} />
|
||||
<Avatar name={user.name} size={32} online={online} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{user.name}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{roleLabels[user.role] ?? user.role}</p>
|
||||
|
||||
@@ -364,6 +364,14 @@ export const api = {
|
||||
req<ChatSearchResult[]>('GET', `/api/hotels/${slug}/chat/search?q=${encodeURIComponent(q)}`),
|
||||
notify: (slug: string, text: string, systemName?: string) =>
|
||||
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/notify`, { text, system_name: systemName }),
|
||||
setPresence: (slug: string) =>
|
||||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/chat/presence`, {}),
|
||||
getPresence: (slug: string) =>
|
||||
req<string[]>('GET', `/api/hotels/${slug}/chat/presence`),
|
||||
setTyping: (slug: string, roomId: string, typing: boolean) =>
|
||||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/typing`, { typing }),
|
||||
getTyping: (slug: string, roomId: string) =>
|
||||
req<string[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/typing`),
|
||||
},
|
||||
|
||||
// ── Workstations ──────────────────────────────────────────────────────────
|
||||
@@ -1333,6 +1341,7 @@ export interface ChatRoom {
|
||||
lastSender: string | null
|
||||
otherUserName: string | null
|
||||
otherUserId: string | null
|
||||
otherUserLastRead: string | null
|
||||
}
|
||||
|
||||
export interface ChatReaction {
|
||||
|
||||
Reference in New Issue
Block a user