feat: chat — group chat creation (create-group view, backend endpoint, group room display)
This commit is contained in:
4
backend/migrations/076_chat_group_rooms.sql
Normal file
4
backend/migrations/076_chat_group_rooms.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Allow 'group' room type (drop old check constraint, add new one)
|
||||||
|
ALTER TABLE chat_rooms DROP CONSTRAINT IF EXISTS chat_rooms_type_check;
|
||||||
|
ALTER TABLE chat_rooms ADD CONSTRAINT chat_rooms_type_check
|
||||||
|
CHECK (type IN ('general', 'direct', 'notifications', 'group'));
|
||||||
@@ -97,12 +97,17 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
(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 u.role 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_role,
|
(SELECT u.role 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_role,
|
||||||
(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 COUNT(*) FROM chat_room_members crm WHERE crm.room_id = r.id) AS member_count,
|
||||||
|
(SELECT json_agg(u.name ORDER BY 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 4) AS member_names,
|
||||||
(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
|
(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 (
|
||||||
SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2
|
SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2
|
||||||
))
|
))
|
||||||
|
AND (r.type != 'group' OR EXISTS (
|
||||||
|
SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2
|
||||||
|
))
|
||||||
ORDER BY last_message_at DESC NULLS LAST, r.type = 'general' DESC`,
|
ORDER BY last_message_at DESC NULLS LAST, r.type = 'general' DESC`,
|
||||||
[hotelId, userId],
|
[hotelId, userId],
|
||||||
)
|
)
|
||||||
@@ -324,6 +329,68 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── POST group room ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fastify.post<SlugParam & { Body: { name: string; memberIds: string[] } }>(
|
||||||
|
'/api/hotels/:slug/chat/group',
|
||||||
|
{ 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 { name, memberIds } = request.body
|
||||||
|
if (!name?.trim()) return reply.code(400).send({ error: 'Name required' })
|
||||||
|
|
||||||
|
const userId = request.user.sub
|
||||||
|
const allMembers = [...new Set([userId, ...memberIds])]
|
||||||
|
|
||||||
|
const { rows: [room] } = await db.query(
|
||||||
|
`INSERT INTO chat_rooms (hotel_id, type, name) VALUES ($1, 'group', $2) RETURNING id`,
|
||||||
|
[hotelId, name.trim()],
|
||||||
|
)
|
||||||
|
const memberValues = allMembers.map((_, i) => `($1, $${i + 2})`).join(', ')
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO chat_room_members (room_id, user_id) VALUES ${memberValues}`,
|
||||||
|
[room.id, ...allMembers],
|
||||||
|
)
|
||||||
|
return reply.code(201).send({ roomId: room.id })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PATCH group room (rename / add/remove members) ────────────────────────
|
||||||
|
|
||||||
|
fastify.patch<RoomParam & { Body: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] } }>(
|
||||||
|
'/api/hotels/:slug/chat/rooms/:roomId/group',
|
||||||
|
{ onRequest: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { slug, roomId } = request.params
|
||||||
|
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||||
|
return reply.code(403).send({ error: 'Forbidden' })
|
||||||
|
|
||||||
|
const { name, addMemberIds = [], removeMemberIds = [] } = request.body
|
||||||
|
if (name) {
|
||||||
|
await db.query('UPDATE chat_rooms SET name = $1 WHERE id = $2', [name.trim(), roomId])
|
||||||
|
}
|
||||||
|
if (addMemberIds.length > 0) {
|
||||||
|
const vals = addMemberIds.map((_, i) => `($1, $${i + 2})`).join(', ')
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO chat_room_members (room_id, user_id) VALUES ${vals} ON CONFLICT DO NOTHING`,
|
||||||
|
[roomId, ...addMemberIds],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (removeMemberIds.length > 0) {
|
||||||
|
await db.query(
|
||||||
|
`DELETE FROM chat_room_members WHERE room_id = $1 AND user_id = ANY($2::uuid[])`,
|
||||||
|
[roomId, removeMemberIds],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ── PATCH mark read ───────────────────────────────────────────────────────
|
// ── PATCH mark read ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
fastify.patch<RoomParam>(
|
fastify.patch<RoomParam>(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
|
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
|
||||||
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin,
|
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, Pin, UserPlus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api'
|
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatReaction } from '../../lib/api'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
@@ -125,7 +125,7 @@ function renderWithMentions(text: string, myName?: string) {
|
|||||||
|
|
||||||
// ── Main ───────────────────────────────────────────────────────────────────
|
// ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type View = 'rooms' | 'messages' | 'search' | 'settings'
|
type View = 'rooms' | 'messages' | 'search' | 'settings' | 'create-group'
|
||||||
|
|
||||||
export function ChatWidget() {
|
export function ChatWidget() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@@ -180,6 +180,11 @@ export function ChatWidget() {
|
|||||||
// @mentions
|
// @mentions
|
||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Create group
|
||||||
|
const [groupName, setGroupName] = useState('')
|
||||||
|
const [groupMemberIds, setGroupMemberIds] = useState<string[]>([])
|
||||||
|
const [creatingGroup, setCreatingGroup] = useState(false)
|
||||||
|
|
||||||
const widgetRef = useRef<HTMLDivElement>(null)
|
const widgetRef = useRef<HTMLDivElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -230,6 +235,7 @@ export function ChatWidget() {
|
|||||||
if (settings.popupEnabled !== false) {
|
if (settings.popupEnabled !== false) {
|
||||||
const rName = room.type === 'general' ? 'Общий чат'
|
const rName = room.type === 'general' ? 'Общий чат'
|
||||||
: room.type === 'notifications' ? 'Уведомления'
|
: room.type === 'notifications' ? 'Уведомления'
|
||||||
|
: room.type === 'group' ? (room.name ?? 'Группа')
|
||||||
: room.otherUserName ?? 'Чат'
|
: room.otherUserName ?? 'Чат'
|
||||||
setToasts(p => [...p.slice(-2), {
|
setToasts(p => [...p.slice(-2), {
|
||||||
id: `${room.id}-${Date.now()}`,
|
id: `${room.id}-${Date.now()}`,
|
||||||
@@ -303,10 +309,10 @@ export function ChatWidget() {
|
|||||||
// Clear typing names when leaving room
|
// Clear typing names when leaving room
|
||||||
useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view])
|
useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view])
|
||||||
|
|
||||||
// Load users list (for search view + @mentions in general chat)
|
// Load users list (for search view + @mentions in general chat + create-group)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
if (view === 'search' || (view === 'messages' && activeRoom?.type === 'general')) {
|
if (view === 'search' || view === 'create-group' || (view === 'messages' && activeRoom?.type === 'general')) {
|
||||||
if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/})
|
if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/})
|
||||||
}
|
}
|
||||||
}, [view, slug, activeRoom?.type, allUsers.length])
|
}, [view, slug, activeRoom?.type, allUsers.length])
|
||||||
@@ -413,7 +419,7 @@ export function ChatWidget() {
|
|||||||
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
|
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
|
||||||
const data = await api.chat.listRooms(slug)
|
const data = await api.chat.listRooms(slug)
|
||||||
setRooms(data)
|
setRooms(data)
|
||||||
await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null })
|
await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null, memberCount: 0, memberNames: null })
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
} catch { /**/ }
|
} catch { /**/ }
|
||||||
}
|
}
|
||||||
@@ -512,6 +518,21 @@ export function ChatWidget() {
|
|||||||
finally { setLoadingOlder(false) }
|
finally { setLoadingOlder(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateGroup = async () => {
|
||||||
|
if (!groupName.trim() || groupMemberIds.length === 0 || creatingGroup) return
|
||||||
|
setCreatingGroup(true)
|
||||||
|
try {
|
||||||
|
const { roomId } = await api.chat.createGroup(slug, groupName.trim(), groupMemberIds)
|
||||||
|
const data = await api.chat.listRooms(slug)
|
||||||
|
setRooms(data)
|
||||||
|
const room = data.find(r => r.id === roomId)
|
||||||
|
setGroupName(''); setGroupMemberIds([])
|
||||||
|
if (room) await openRoom(room)
|
||||||
|
else setView('rooms')
|
||||||
|
} catch { /**/ }
|
||||||
|
finally { setCreatingGroup(false) }
|
||||||
|
}
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
if (view === 'messages') {
|
if (view === 'messages') {
|
||||||
sendTyping(false)
|
sendTyping(false)
|
||||||
@@ -521,10 +542,14 @@ export function ChatWidget() {
|
|||||||
}
|
}
|
||||||
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')
|
||||||
|
else if (view === 'create-group') { setView('rooms'); setGroupName(''); setGroupMemberIds([]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomDisplayName = (room: ChatRoom) =>
|
const roomDisplayName = (room: ChatRoom) =>
|
||||||
room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.otherUserName ?? 'Чат'
|
room.type === 'general' ? 'Общий чат'
|
||||||
|
: room.type === 'notifications' ? 'Уведомления'
|
||||||
|
: room.type === 'group' ? (room.name ?? 'Группа')
|
||||||
|
: room.otherUserName ?? 'Чат'
|
||||||
|
|
||||||
const isOtherOnline = (room: ChatRoom) =>
|
const isOtherOnline = (room: ChatRoom) =>
|
||||||
room.type === 'direct' && room.otherUserId ? onlineIds.includes(room.otherUserId) : false
|
room.type === 'direct' && room.otherUserId ? onlineIds.includes(room.otherUserId) : false
|
||||||
@@ -572,6 +597,7 @@ export function ChatWidget() {
|
|||||||
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
|
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
|
||||||
{view === 'search' && 'Поиск'}
|
{view === 'search' && 'Поиск'}
|
||||||
{view === 'settings' && 'Настройки чата'}
|
{view === 'settings' && 'Настройки чата'}
|
||||||
|
{view === 'create-group' && 'Новая группа'}
|
||||||
</p>
|
</p>
|
||||||
{/* Online dot in messages header */}
|
{/* Online dot in messages header */}
|
||||||
{view === 'messages' && activeRoom && isOtherOnline(activeRoom) && (
|
{view === 'messages' && activeRoom && isOtherOnline(activeRoom) && (
|
||||||
@@ -595,15 +621,18 @@ export function ChatWidget() {
|
|||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
{view === 'messages' && activeRoom?.type === 'group' && activeRoom.memberCount && (
|
||||||
|
<p className="text-xs text-white/70">{activeRoom.memberCount} участников</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>
|
||||||
)}
|
)}
|
||||||
</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')} 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" title="Поиск"><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={() => { setGroupName(''); setGroupMemberIds([]); setView('create-group') }} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Новая группа"><UserPlus size={15} /></button>
|
||||||
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"><Settings size={15} /></button>
|
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Настройки"><Settings size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{view === 'messages' && activeRoom?.type !== 'notifications' && (
|
{view === 'messages' && activeRoom?.type !== 'notifications' && (
|
||||||
@@ -721,6 +750,63 @@ export function ChatWidget() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Create Group ── */}
|
||||||
|
{view === 'create-group' && (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="px-3 pt-3 pb-2 shrink-0 border-b border-slate-100 dark:border-slate-700">
|
||||||
|
<input
|
||||||
|
value={groupName}
|
||||||
|
onChange={e => setGroupName(e.target.value)}
|
||||||
|
placeholder="Название группы..."
|
||||||
|
maxLength={50}
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2 rounded-xl text-sm bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none border border-transparent focus:border-brand-400"
|
||||||
|
/>
|
||||||
|
{groupMemberIds.length > 0 && (
|
||||||
|
<p className="text-xs text-slate-500 mt-1.5 px-0.5">Выбрано: {groupMemberIds.length} участника(-ов)</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{allUsers.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8"><Loader2 size={18} className="animate-spin text-slate-400" /></div>
|
||||||
|
) : (
|
||||||
|
allUsers.filter(u => u.id !== user?.id).map(u => {
|
||||||
|
const selected = groupMemberIds.includes(u.id)
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||||||
|
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button key={u.id}
|
||||||
|
onClick={() => setGroupMemberIds(prev => selected ? prev.filter(id => id !== u.id) : [...prev, u.id])}
|
||||||
|
className={cn('w-full flex items-center gap-3 px-4 py-2.5 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left', selected ? 'bg-brand-50 dark:bg-brand-900/20' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50')}>
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar name={u.name} size={32} />
|
||||||
|
{selected && (
|
||||||
|
<span className="absolute inset-0 rounded-full bg-brand-600/80 flex items-center justify-center text-white text-xs font-bold">✓</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{u.name}</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">{roleLabels[u.role] ?? u.role}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-3 border-t border-slate-100 dark:border-slate-700 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => void handleCreateGroup()}
|
||||||
|
disabled={!groupName.trim() || groupMemberIds.length === 0 || creatingGroup}
|
||||||
|
className="w-full py-2 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white text-sm font-medium transition-colors flex items-center justify-center gap-2">
|
||||||
|
{creatingGroup ? <Loader2 size={15} className="animate-spin" /> : <Users size={15} />}
|
||||||
|
Создать группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Messages ── */}
|
{/* ── Messages ── */}
|
||||||
{view === 'messages' && (
|
{view === 'messages' && (
|
||||||
<>
|
<>
|
||||||
@@ -955,6 +1041,10 @@ function RoomRow({ room, isPinned, isMuted, isOnline, onClick, onMenuClick }: {
|
|||||||
<div className="w-9 h-9 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
<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" />
|
<Bell size={16} className="text-amber-600 dark:text-amber-400" />
|
||||||
</div>
|
</div>
|
||||||
|
) : room.type === 'group' ? (
|
||||||
|
<div className="w-9 h-9 rounded-full bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
|
||||||
|
<Users size={16} className="text-violet-600 dark:text-violet-400" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Avatar name={room.otherUserName ?? '?'} size={36} online={isOnline} />
|
<Avatar name={room.otherUserName ?? '?'} size={36} online={isOnline} />
|
||||||
)}
|
)}
|
||||||
@@ -971,7 +1061,7 @@ function RoomRow({ room, isPinned, isMuted, isOnline, onClick, onMenuClick }: {
|
|||||||
{isPinned && <Pin size={10} className="text-brand-500 shrink-0" />}
|
{isPinned && <Pin size={10} className="text-brand-500 shrink-0" />}
|
||||||
{isMuted && <BellOff size={10} className="text-slate-400 shrink-0" />}
|
{isMuted && <BellOff size={10} className="text-slate-400 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')}>
|
<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}
|
{room.type === 'general' ? 'Общий чат' : room.type === 'notifications' ? 'Уведомления' : room.type === 'group' ? (room.name ?? 'Группа') : room.otherUserName}
|
||||||
</p>
|
</p>
|
||||||
{!hovered && room.lastMessageAt && (
|
{!hovered && room.lastMessageAt && (
|
||||||
<span className="text-[10px] text-slate-400 shrink-0">{fmtTime(room.lastMessageAt)}</span>
|
<span className="text-[10px] text-slate-400 shrink-0">{fmtTime(room.lastMessageAt)}</span>
|
||||||
|
|||||||
@@ -356,6 +356,8 @@ export const api = {
|
|||||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
||||||
openDirect: (slug: string, otherUserId: string) =>
|
openDirect: (slug: string, otherUserId: string) =>
|
||||||
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
|
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
|
||||||
|
createGroup: (slug: string, name: string, memberIds: string[]) =>
|
||||||
|
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/group`, { name, memberIds }),
|
||||||
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>
|
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>
|
||||||
req<ChatMessage>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`, { text }),
|
req<ChatMessage>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`, { text }),
|
||||||
deleteMessage: (slug: string, roomId: string, msgId: string) =>
|
deleteMessage: (slug: string, roomId: string, msgId: string) =>
|
||||||
@@ -1335,7 +1337,7 @@ export interface LoyaltyTransaction {
|
|||||||
|
|
||||||
export interface ChatRoom {
|
export interface ChatRoom {
|
||||||
id: string
|
id: string
|
||||||
type: 'general' | 'direct' | 'notifications'
|
type: 'general' | 'direct' | 'notifications' | 'group'
|
||||||
name: string | null
|
name: string | null
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
lastMessage: string | null
|
lastMessage: string | null
|
||||||
@@ -1345,6 +1347,8 @@ export interface ChatRoom {
|
|||||||
otherUserRole: string | null
|
otherUserRole: string | null
|
||||||
otherUserId: string | null
|
otherUserId: string | null
|
||||||
otherUserLastRead: string | null
|
otherUserLastRead: string | null
|
||||||
|
memberCount: number
|
||||||
|
memberNames: string[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatReaction {
|
export interface ChatReaction {
|
||||||
|
|||||||
Reference in New Issue
Block a user