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 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 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 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
|
FROM chat_rooms r
|
||||||
WHERE r.hotel_id = $1
|
WHERE r.hotel_id = $1
|
||||||
AND (r.type IN ('general', 'notifications') OR EXISTS (
|
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 ───────────────────────────────────────────────────────────
|
// ── POST notify ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fastify.post<SlugParam & { Body: { text: string; system_name?: string } }>(
|
fastify.post<SlugParam & { Body: { text: string; system_name?: string } }>(
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ function fmtTime(iso: string) {
|
|||||||
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
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 (
|
return (
|
||||||
|
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||||
<div style={{ width: size, height: size, background: avatarColor(name), fontSize: size * 0.38 }}
|
<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">
|
className="rounded-full flex items-center justify-center text-white font-semibold">
|
||||||
{initials(name)}
|
{initials(name)}
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
function SystemAvatar({ size = 28 }: { size?: number }) {
|
function SystemAvatar({ size = 28 }: { size?: number }) {
|
||||||
@@ -49,8 +54,7 @@ function playNotifSound() {
|
|||||||
try {
|
try {
|
||||||
const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||||
const ctx = new AudioCtx()
|
const ctx = new AudioCtx()
|
||||||
const osc = ctx.createOscillator()
|
const osc = ctx.createOscillator(), gain = ctx.createGain()
|
||||||
const gain = ctx.createGain()
|
|
||||||
osc.connect(gain); gain.connect(ctx.destination)
|
osc.connect(gain); gain.connect(ctx.destination)
|
||||||
osc.frequency.value = 880
|
osc.frequency.value = 880
|
||||||
gain.gain.setValueAtTime(0.18, ctx.currentTime)
|
gain.gain.setValueAtTime(0.18, ctx.currentTime)
|
||||||
@@ -60,7 +64,7 @@ function playNotifSound() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────────────
|
// ── Persist ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ChatSettings { notifVisible: boolean; soundEnabled: boolean }
|
interface ChatSettings { notifVisible: boolean; soundEnabled: boolean }
|
||||||
const SETTINGS_KEY = 'hotelsync-chat-settings'
|
const SETTINGS_KEY = 'hotelsync-chat-settings'
|
||||||
@@ -70,11 +74,10 @@ function loadSettings(): ChatSettings {
|
|||||||
try {
|
try {
|
||||||
const s = localStorage.getItem(SETTINGS_KEY)
|
const s = localStorage.getItem(SETTINGS_KEY)
|
||||||
if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial<ChatSettings> }
|
if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial<ChatSettings> }
|
||||||
} catch { /* ignore */ }
|
} catch { /**/ }
|
||||||
return { notifVisible: true, soundEnabled: false }
|
return { notifVisible: true, soundEnabled: false }
|
||||||
}
|
}
|
||||||
function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) }
|
function saveSettings(s: ChatSettings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) }
|
||||||
|
|
||||||
function loadPins(): string[] {
|
function loadPins(): string[] {
|
||||||
try { return JSON.parse(localStorage.getItem(PINS_KEY) ?? '[]') as string[] } catch { return [] }
|
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: 'message'; x: number; y: number; msg: ChatMessage }
|
||||||
| { type: 'room'; x: number; y: number; room: ChatRoom }
|
| { type: 'room'; x: number; y: number; room: ChatRoom }
|
||||||
|
|
||||||
// ── Emojis ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅']
|
const EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','✅']
|
||||||
|
|
||||||
// ── Main component ─────────────────────────────────────────────────────────
|
// ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type View = 'rooms' | 'messages' | 'search' | 'settings'
|
type View = 'rooms' | 'messages' | 'search' | 'settings'
|
||||||
|
|
||||||
@@ -117,6 +118,10 @@ export function ChatWidget() {
|
|||||||
// Context menu
|
// Context menu
|
||||||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||||||
|
|
||||||
|
// Typing & presence
|
||||||
|
const [typingNames, setTypingNames] = useState<string[]>([])
|
||||||
|
const [onlineIds, setOnlineIds] = useState<string[]>([])
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [searchUsers, setSearchUsers] = useState<User[]>([])
|
const [searchUsers, setSearchUsers] = useState<User[]>([])
|
||||||
@@ -135,36 +140,32 @@ export function ChatWidget() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const prevUnreadRef = useRef<number>(0)
|
const prevUnreadRef = useRef<number>(0)
|
||||||
const prevMsgCountRef = 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)
|
const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
||||||
|
|
||||||
// ── Sorted rooms (pinned first, then by last message) ────────────────────
|
|
||||||
|
|
||||||
const visibleRooms = [...rooms]
|
const visibleRooms = [...rooms]
|
||||||
.filter(r => !(r.type === 'notifications' && !settings.notifVisible))
|
.filter(r => !(r.type === 'notifications' && !settings.notifVisible))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => (pinnedIds.includes(a.id) ? 0 : 1) - (pinnedIds.includes(b.id) ? 0 : 1))
|
||||||
const aPin = pinnedIds.includes(a.id) ? 0 : 1
|
|
||||||
const bPin = pinnedIds.includes(b.id) ? 0 : 1
|
|
||||||
return aPin - bPin
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Loaders ──────────────────────────────────────────────────────────────
|
// ── Effects ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const loadRooms = useCallback(async () => {
|
const loadRooms = useCallback(async () => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
try {
|
try {
|
||||||
const data = await api.chat.listRooms(slug)
|
const data = await api.chat.listRooms(slug)
|
||||||
setRooms(data)
|
setRooms(data)
|
||||||
// Sound on new unread when widget is closed
|
|
||||||
if (settings.soundEnabled && !open) {
|
if (settings.soundEnabled && !open) {
|
||||||
const newUnread = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
const n = data.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
||||||
if (newUnread > prevUnreadRef.current) playNotifSound()
|
if (n > prevUnreadRef.current) playNotifSound()
|
||||||
prevUnreadRef.current = newUnread
|
prevUnreadRef.current = n
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /**/ }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [slug, settings.soundEnabled, open])
|
}, [slug, settings.soundEnabled, open])
|
||||||
|
|
||||||
|
// Poll rooms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !slug) return
|
if (!open || !slug) return
|
||||||
setLoadingRooms(true)
|
setLoadingRooms(true)
|
||||||
@@ -173,34 +174,52 @@ export function ChatWidget() {
|
|||||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||||
}, [open, slug, loadRooms])
|
}, [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
|
// Auto-scroll
|
||||||
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||||
|
|
||||||
// Poll messages
|
// Poll messages + typing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== 'messages' || !activeRoom) return
|
if (view !== 'messages' || !activeRoom) return
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const msgs = await api.chat.getMessages(slug, activeRoom.id)
|
const [msgs, typing] = await Promise.all([
|
||||||
// Sound on new messages in active room
|
api.chat.getMessages(slug, activeRoom.id),
|
||||||
|
api.chat.getTyping(slug, activeRoom.id),
|
||||||
|
])
|
||||||
if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) {
|
if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) {
|
||||||
const lastNew = msgs[msgs.length - 1]
|
const last = msgs[msgs.length - 1]
|
||||||
if (lastNew.senderId !== user?.id) playNotifSound()
|
if (last.senderId !== user?.id) playNotifSound()
|
||||||
}
|
}
|
||||||
prevMsgCountRef.current = msgs.length
|
prevMsgCountRef.current = msgs.length
|
||||||
setMessages(msgs)
|
setMessages(msgs)
|
||||||
} catch { /* ignore */ }
|
setTypingNames(typing)
|
||||||
}, 3000)
|
} catch { /**/ }
|
||||||
|
}, 2000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [view, activeRoom, slug, settings.soundEnabled, user?.id])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (view !== 'search' || !slug) return
|
if (view !== 'search' || !slug) return
|
||||||
api.users.list(slug).then(setAllUsers).catch(() => { /* ignore */ })
|
api.users.list(slug).then(setAllUsers).catch(() => {/**/})
|
||||||
}, [view, slug])
|
}, [view, slug])
|
||||||
|
|
||||||
// Search
|
// Search effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== 'search') return
|
if (view !== 'search') return
|
||||||
const q = searchQuery.trim()
|
const q = searchQuery.trim()
|
||||||
@@ -210,7 +229,7 @@ export function ChatWidget() {
|
|||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
if (searchTab !== 'messages') return
|
if (searchTab !== 'messages') return
|
||||||
setLoadingSearch(true)
|
setLoadingSearch(true)
|
||||||
try { setSearchResults(await api.chat.search(slug, q)) } catch { /* ignore */ }
|
try { setSearchResults(await api.chat.search(slug, q)) } catch { /**/ }
|
||||||
finally { setLoadingSearch(false) }
|
finally { setLoadingSearch(false) }
|
||||||
}, 400)
|
}, 400)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
@@ -220,29 +239,43 @@ export function ChatWidget() {
|
|||||||
if (view === 'search') setTimeout(() => searchInputRef.current?.focus(), 50)
|
if (view === 'search') setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||||
}, [view])
|
}, [view])
|
||||||
|
|
||||||
// Close context menu on outside click
|
// Close ctx menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ctxMenu) return
|
if (!ctxMenu) return
|
||||||
const handler = () => setCtxMenu(null)
|
const h = () => setCtxMenu(null)
|
||||||
window.addEventListener('click', handler)
|
window.addEventListener('click', h)
|
||||||
window.addEventListener('contextmenu', handler)
|
return () => window.removeEventListener('click', h)
|
||||||
return () => { window.removeEventListener('click', handler); window.removeEventListener('contextmenu', handler) }
|
|
||||||
}, [ctxMenu])
|
}, [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 ───────────────────────────────────────────────────────────────
|
// ── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const openRoom = async (room: ChatRoom) => {
|
const openRoom = async (room: ChatRoom) => {
|
||||||
setActiveRoom(room)
|
setActiveRoom(room); setView('messages'); setLoadingMsgs(true)
|
||||||
setView('messages')
|
prevMsgCountRef.current = 0; isTypingRef.current = false
|
||||||
setLoadingMsgs(true)
|
|
||||||
prevMsgCountRef.current = 0
|
|
||||||
try {
|
try {
|
||||||
const msgs = await api.chat.getMessages(slug, room.id)
|
const msgs = await api.chat.getMessages(slug, room.id)
|
||||||
prevMsgCountRef.current = msgs.length
|
prevMsgCountRef.current = msgs.length
|
||||||
setMessages(msgs)
|
setMessages(msgs)
|
||||||
await api.chat.markRead(slug, room.id)
|
await api.chat.markRead(slug, room.id)
|
||||||
setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
|
setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
|
||||||
} catch { /* ignore */ }
|
} catch { /**/ }
|
||||||
finally { setLoadingMsgs(false) }
|
finally { setLoadingMsgs(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,9 +283,9 @@ export function ChatWidget() {
|
|||||||
try {
|
try {
|
||||||
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
|
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
|
||||||
await loadRooms()
|
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('')
|
setSearchQuery('')
|
||||||
} catch { /* ignore */ }
|
} catch { /**/ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSearchResult = async (result: ChatSearchResult) => {
|
const openSearchResult = async (result: ChatSearchResult) => {
|
||||||
@@ -261,25 +294,22 @@ export function ChatWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]; if (!file) return
|
||||||
if (!file) return
|
|
||||||
setAttachment(file)
|
setAttachment(file)
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = ev => setAttachPreview(ev.target?.result as string)
|
reader.onload = ev => setAttachPreview(ev.target?.result as string)
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file); e.target.value = ''
|
||||||
e.target.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeAttachment = () => { setAttachment(null); setAttachPreview(null) }
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if ((!text.trim() && !attachment) || !activeRoom || sending) return
|
if ((!text.trim() && !attachment) || !activeRoom || sending) return
|
||||||
const t = text.trim(), file = attachment
|
const t = text.trim(), file = attachment
|
||||||
setText(''); setAttachment(null); setAttachPreview(null); setSending(true)
|
setText(''); setAttachment(null); setAttachPreview(null); setSending(true)
|
||||||
|
sendTyping(false)
|
||||||
try {
|
try {
|
||||||
let attachmentUrl: string | undefined
|
let url: string | undefined
|
||||||
if (file) { const { url } = await api.chat.uploadImage(file); attachmentUrl = url }
|
if (file) { url = (await api.chat.uploadImage(file)).url }
|
||||||
const msg = await api.chat.sendMessage(slug, activeRoom.id, t, attachmentUrl)
|
const msg = await api.chat.sendMessage(slug, activeRoom.id, t, url)
|
||||||
setMessages(prev => { prevMsgCountRef.current = prev.length + 1; return [...prev, msg] })
|
setMessages(prev => { prevMsgCountRef.current = prev.length + 1; return [...prev, msg] })
|
||||||
} catch {
|
} catch {
|
||||||
setText(t)
|
setText(t)
|
||||||
@@ -297,27 +327,24 @@ export function ChatWidget() {
|
|||||||
try {
|
try {
|
||||||
const updated = await api.chat.editMessage(slug, activeRoom.id, msg.id, editText)
|
const updated = await api.chat.editMessage(slug, activeRoom.id, msg.id, editText)
|
||||||
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
|
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
|
||||||
} catch { /* ignore */ }
|
} catch { /**/ }
|
||||||
setEditingMsgId(null)
|
setEditingMsgId(null)
|
||||||
}
|
}
|
||||||
const cancelEdit = () => setEditingMsgId(null)
|
|
||||||
|
|
||||||
const deleteMsg = async (msg: ChatMessage) => {
|
const deleteMsg = async (msg: ChatMessage) => {
|
||||||
if (!activeRoom) return
|
if (!activeRoom) return; setCtxMenu(null)
|
||||||
setCtxMenu(null)
|
|
||||||
try {
|
try {
|
||||||
await api.chat.deleteMessage(slug, activeRoom.id, msg.id)
|
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))
|
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) => {
|
const toggleReaction = async (msg: ChatMessage, emoji: string) => {
|
||||||
if (!activeRoom) return
|
if (!activeRoom) return; setCtxMenu(null)
|
||||||
setCtxMenu(null)
|
|
||||||
try {
|
try {
|
||||||
const updated = await api.chat.toggleReaction(slug, activeRoom.id, msg.id, emoji)
|
const updated = await api.chat.toggleReaction(slug, activeRoom.id, msg.id, emoji)
|
||||||
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
|
setMessages(prev => prev.map(m => m.id === msg.id ? updated : m))
|
||||||
} catch { /* ignore */ }
|
} catch { /**/ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const togglePin = (roomId: string) => {
|
const togglePin = (roomId: string) => {
|
||||||
@@ -331,7 +358,10 @@ export function ChatWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goBack = () => {
|
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 === 'search') { setView('rooms'); setSearchQuery('') }
|
||||||
else if (view === 'settings') setView('rooms')
|
else if (view === 'settings') setView('rooms')
|
||||||
}
|
}
|
||||||
@@ -339,15 +369,13 @@ export function ChatWidget() {
|
|||||||
const roomDisplayName = (room: ChatRoom) =>
|
const roomDisplayName = (room: ChatRoom) =>
|
||||||
room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат'
|
room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат'
|
||||||
|
|
||||||
const onMsgContextMenu = (e: React.MouseEvent, msg: ChatMessage) => {
|
const isOtherOnline = (room: ChatRoom) =>
|
||||||
if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return
|
room.type === 'direct' && room.otherUserId ? onlineIds.includes(room.otherUserId) : false
|
||||||
e.preventDefault()
|
|
||||||
setCtxMenu({ type: 'message', x: e.clientX, y: e.clientY, msg })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRoomContextMenu = (e: React.MouseEvent, room: ChatRoom) => {
|
// Read receipt: is message read by other side?
|
||||||
e.preventDefault()
|
const isRead = (msg: ChatMessage) => {
|
||||||
setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, room })
|
if (!activeRoom || activeRoom.type !== 'direct' || !activeRoom.otherUserLastRead) return false
|
||||||
|
return new Date(msg.createdAt) <= new Date(activeRoom.otherUserLastRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!slug) return null
|
if (!slug) return null
|
||||||
@@ -356,15 +384,9 @@ export function ChatWidget() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Floating button */}
|
{/* Float button */}
|
||||||
<button
|
<button onClick={() => setOpen(v => !v)}
|
||||||
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')}>
|
||||||
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 ? <X size={22} /> : <MessageSquare size={22} />}
|
||||||
{!open && totalUnread > 0 && (
|
{!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">
|
<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>
|
</button>
|
||||||
|
|
||||||
{/* Chat panel */}
|
{/* Panel */}
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<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 }}>
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* 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">
|
<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' && (
|
{view !== 'rooms' && (
|
||||||
@@ -391,14 +407,21 @@ export function ChatWidget() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-semibold text-sm truncate">
|
<p className="font-semibold text-sm truncate">
|
||||||
{view === 'rooms' && 'Чат сотрудников'}
|
{view === 'rooms' && 'Чат сотрудников'}
|
||||||
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
|
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
|
||||||
{view === 'search' && 'Поиск'}
|
{view === 'search' && 'Поиск'}
|
||||||
{view === 'settings' && 'Настройки чата'}
|
{view === 'settings' && 'Настройки чата'}
|
||||||
</p>
|
</p>
|
||||||
{view === 'rooms' && visibleRooms.length > 0 && (
|
{/* Online dot in messages header */}
|
||||||
<p className="text-xs text-white/70">{visibleRooms.length} чатов</p>
|
{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' && (
|
{view === 'messages' && activeRoom?.type === 'notifications' && (
|
||||||
<p className="text-xs text-white/70">Только чтение</p>
|
<p className="text-xs text-white/70">Только чтение</p>
|
||||||
@@ -406,15 +429,9 @@ export function ChatWidget() {
|
|||||||
</div>
|
</div>
|
||||||
{view === 'rooms' && (
|
{view === 'rooms' && (
|
||||||
<div className="flex items-center gap-0.5">
|
<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">
|
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Search size={15} /></button>
|
||||||
<Search size={15} />
|
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><PenSquare size={15} /></button>
|
||||||
</button>
|
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Settings 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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -429,48 +446,14 @@ export function ChatWidget() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
visibleRooms.map(room => (
|
visibleRooms.map(room => (
|
||||||
<button
|
<RoomRow
|
||||||
key={room.id}
|
key={room.id}
|
||||||
|
room={room}
|
||||||
|
isPinned={pinnedIds.includes(room.id)}
|
||||||
|
isOnline={isOtherOnline(room)}
|
||||||
onClick={() => openRoom(room)}
|
onClick={() => openRoom(room)}
|
||||||
onContextMenu={e => onRoomContextMenu(e, room)}
|
onMenuClick={e => { e.stopPropagation(); setCtxMenu({ type: 'room', x: e.clientX, y: e.clientY, 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>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -488,24 +471,25 @@ export function ChatWidget() {
|
|||||||
{searchQuery && <button onClick={() => setSearchQuery('')} className="text-slate-400 hover:text-slate-600"><X size={13} /></button>}
|
{searchQuery && <button onClick={() => setSearchQuery('')} className="text-slate-400 hover:text-slate-600"><X size={13} /></button>}
|
||||||
</div>
|
</div>
|
||||||
</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 => (
|
{(['people', 'messages'] as const).map(tab => (
|
||||||
<button key={tab} onClick={() => setSearchTab(tab)}
|
<button key={tab} onClick={() => setSearchTab(tab)}
|
||||||
className={cn('flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors',
|
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')}>
|
||||||
searchTab === tab ? 'bg-brand-600 text-white' : 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700')}>
|
|
||||||
{tab === 'people' ? 'Люди' : 'Сообщения'}
|
{tab === 'people' ? 'Люди' : 'Сообщения'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto mt-2">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{searchTab === 'people' && (
|
{searchTab === 'people' && (
|
||||||
!searchQuery
|
!searchQuery
|
||||||
? allUsers.length === 0
|
? allUsers.length === 0
|
||||||
? <div className="text-center py-6 text-xs text-slate-400">Загрузка...</div>
|
? <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
|
: searchUsers.length === 0
|
||||||
? <div className="text-center py-6 text-xs text-slate-400">Никого не найдено</div>
|
? <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' && (
|
{searchTab === 'messages' && (
|
||||||
!searchQuery
|
!searchQuery
|
||||||
@@ -534,25 +518,19 @@ export function ChatWidget() {
|
|||||||
{view === 'settings' && (
|
{view === 'settings' && (
|
||||||
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
||||||
<SettingsSection title="Канал уведомлений">
|
<SettingsSection title="Канал уведомлений">
|
||||||
<ToggleRow
|
<ToggleRow icon={settings.notifVisible ? <Bell size={15} /> : <BellOff size={15} />}
|
||||||
icon={settings.notifVisible ? <Bell size={15} /> : <BellOff size={15} />}
|
label="Показывать канал уведомлений" checked={settings.notifVisible}
|
||||||
label="Показывать канал уведомлений"
|
onChange={v => updateSettings({ notifVisible: v })} />
|
||||||
checked={settings.notifVisible}
|
|
||||||
onChange={v => updateSettings({ notifVisible: v })}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<SettingsSection title="Звук">
|
<SettingsSection title="Звук">
|
||||||
<ToggleRow
|
<ToggleRow icon={settings.soundEnabled ? <Volume2 size={15} /> : <VolumeX size={15} />}
|
||||||
icon={settings.soundEnabled ? <Volume2 size={15} /> : <VolumeX size={15} />}
|
label="Звуковое уведомление" checked={settings.soundEnabled}
|
||||||
label="Звуковое уведомление"
|
onChange={v => updateSettings({ soundEnabled: v })} />
|
||||||
checked={settings.soundEnabled}
|
|
||||||
onChange={v => updateSettings({ soundEnabled: v })}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<SettingsSection title="Подсказка">
|
<SettingsSection title="Подсказка">
|
||||||
<p className="text-xs text-slate-500 leading-relaxed px-4">
|
<p className="text-xs text-slate-500 leading-relaxed">
|
||||||
Правый клик на сообщении — реакции, редактирование, удаление.<br />
|
Правый клик на сообщении — реакции, редактирование, удаление.<br />
|
||||||
Правый клик на чате — закрепить / открепить сверху.
|
Кнопка ⋮ на чате — закрепить / открепить сверху.
|
||||||
</p>
|
</p>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,35 +554,53 @@ export function ChatWidget() {
|
|||||||
isOwn={!msg.isSystem && msg.senderId === user?.id}
|
isOwn={!msg.isSystem && msg.senderId === user?.id}
|
||||||
isEditing={editingMsgId === msg.id}
|
isEditing={editingMsgId === msg.id}
|
||||||
editText={editText}
|
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}
|
onEditTextChange={setEditText}
|
||||||
onSaveEdit={() => saveEdit(msg)}
|
onSaveEdit={() => saveEdit(msg)}
|
||||||
onCancelEdit={cancelEdit}
|
onCancelEdit={() => setEditingMsgId(null)}
|
||||||
onReaction={emoji => toggleReaction(msg, emoji)}
|
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 ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
{activeRoom?.type !== 'notifications' && (
|
{activeRoom?.type !== 'notifications' && (
|
||||||
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||||
{attachPreview && (
|
{attachPreview && (
|
||||||
<div className="relative inline-block mb-2">
|
<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" />
|
<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} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-end gap-1.5">
|
<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} />
|
<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">
|
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} />
|
<Paperclip size={16} />
|
||||||
</button>
|
</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}
|
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" />
|
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}
|
<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 }: {
|
function RoomRow({ room, isPinned, isOnline, onClick, onMenuClick }: {
|
||||||
msg: ChatMessage; isOwn: boolean; isEditing: boolean; editText: string
|
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
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
onEditTextChange: (v: string) => void
|
onEditTextChange: (v: string) => void
|
||||||
onSaveEdit: () => void; onCancelEdit: () => void
|
onSaveEdit: () => void; onCancelEdit: () => void
|
||||||
onReaction: (emoji: string) => void
|
onReaction: (emoji: string) => void
|
||||||
}) {
|
}) {
|
||||||
const isDeleted = !!msg.deletedAt
|
const isDeleted = !!msg.deletedAt, isSystem = msg.isSystem
|
||||||
const isSystem = msg.isSystem
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex gap-2', isOwn && 'flex-row-reverse')} onContextMenu={onContextMenu}>
|
<div className={cn('flex gap-2', isOwn && 'flex-row-reverse')} onContextMenu={onContextMenu}>
|
||||||
{!isOwn && (isSystem ? <SystemAvatar size={24} /> : <Avatar name={msg.senderName} size={24} />)}
|
{!isOwn && (isSystem ? <SystemAvatar size={24} /> : <Avatar name={msg.senderName} size={24} />)}
|
||||||
@@ -695,13 +755,20 @@ function MessageBubble({ msg, isOwn, isEditing, editText, onContextMenu, onEditT
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Meta: time + edited + read receipt */}
|
||||||
{!isDeleted && (
|
{!isDeleted && (
|
||||||
<div className={cn('flex items-center gap-1 mt-0.5 mx-1', isOwn && 'flex-row-reverse')}>
|
<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>
|
<p className="text-[10px] text-slate-400">{fmtTime(msg.createdAt)}</p>
|
||||||
{msg.editedAt && <p className="text-[10px] text-slate-400">· изм.</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reactions */}
|
||||||
{!isDeleted && msg.reactions?.length > 0 && (
|
{!isDeleted && msg.reactions?.length > 0 && (
|
||||||
<div className={cn('flex flex-wrap gap-1 mt-1', isOwn && 'justify-end')}>
|
<div className={cn('flex flex-wrap gap-1 mt-1', isOwn && 'justify-end')}>
|
||||||
{(msg.reactions as ChatReaction[]).map(r => (
|
{(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 }: {
|
function ContextMenu({ menu, currentUserId, pinnedIds, onReaction, onEdit, onDelete, onTogglePin, onClose }: {
|
||||||
menu: CtxMenu; currentUserId: string; pinnedIds: string[]
|
menu: CtxMenu; currentUserId: string; pinnedIds: string[]
|
||||||
onReaction: (emoji: string) => void; onEdit: () => void; onDelete: () => void
|
onReaction: (emoji: string) => void; onEdit: () => void; onDelete: () => void
|
||||||
onTogglePin: () => void; onClose: () => void
|
onTogglePin: () => void; onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
// Clamp to viewport
|
const menuW = 182, menuH = menu.type === 'message' ? 160 : 56
|
||||||
const menuW = 180, menuH = menu.type === 'message' ? 160 : 60
|
|
||||||
const x = Math.min(menu.x, window.innerWidth - menuW - 8)
|
const x = Math.min(menu.x, window.innerWidth - menuW - 8)
|
||||||
const y = Math.min(menu.y, window.innerHeight - menuH - 8)
|
const y = Math.min(menu.y, window.innerHeight - menuH - 8)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ position: 'fixed', top: y, left: x, zIndex: 9999, minWidth: menuW }}
|
||||||
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"
|
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' && (
|
{menu.type === 'message' && (
|
||||||
<>
|
<>
|
||||||
{/* Emoji row */}
|
|
||||||
<div className="flex justify-around px-2 py-1.5 border-b border-slate-100 dark:border-slate-700">
|
<div className="flex justify-around px-2 py-1.5 border-b border-slate-100 dark:border-slate-700">
|
||||||
{EMOJIS.map(e => (
|
{EMOJIS.map(e => (
|
||||||
<button key={e} onClick={() => { onReaction(e); onClose() }}
|
<button key={e} onClick={() => { onReaction(e); onClose() }}
|
||||||
className="text-base hover:scale-125 transition-transform leading-none p-0.5">
|
className="text-base hover:scale-125 transition-transform leading-none p-0.5">{e}</button>
|
||||||
{e}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Actions */}
|
|
||||||
{menu.msg.senderId === currentUserId && (
|
{menu.msg.senderId === currentUserId && (
|
||||||
<button onClick={() => { onEdit(); onClose() }}
|
<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">
|
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> = {
|
const roleLabels: Record<string, string> = {
|
||||||
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||||||
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
||||||
}
|
}
|
||||||
return (
|
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">
|
<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">
|
<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-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>
|
<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)}`),
|
req<ChatSearchResult[]>('GET', `/api/hotels/${slug}/chat/search?q=${encodeURIComponent(q)}`),
|
||||||
notify: (slug: string, text: string, systemName?: string) =>
|
notify: (slug: string, text: string, systemName?: string) =>
|
||||||
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/notify`, { text, system_name: systemName }),
|
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 ──────────────────────────────────────────────────────────
|
// ── Workstations ──────────────────────────────────────────────────────────
|
||||||
@@ -1333,6 +1341,7 @@ export interface ChatRoom {
|
|||||||
lastSender: string | null
|
lastSender: string | null
|
||||||
otherUserName: string | null
|
otherUserName: string | null
|
||||||
otherUserId: string | null
|
otherUserId: string | null
|
||||||
|
otherUserLastRead: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatReaction {
|
export interface ChatReaction {
|
||||||
|
|||||||
Reference in New Issue
Block a user