feat: chat — role on hover, role in direct header, @mention dropdown
- MessageBubble: role hidden by default, appears on hover of sender name (group/name CSS) - Direct chat header: shows other user's role (from otherUserRole field added to backend) - @mention: typing @ triggers filtered user dropdown, click inserts @Name into message - Hint text updated: '@ — упомянуть' - Backend: other_user_role added to rooms query Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,7 @@ 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 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 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
|
||||||
|
|||||||
@@ -159,7 +159,11 @@ export function ChatWidget() {
|
|||||||
const [roomSearchOpen, setRoomSearchOpen] = useState(false)
|
const [roomSearchOpen, setRoomSearchOpen] = useState(false)
|
||||||
const roomSearchInputRef = useRef<HTMLInputElement>(null)
|
const roomSearchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// @mentions
|
||||||
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
|
||||||
|
|
||||||
const widgetRef = useRef<HTMLDivElement>(null)
|
const widgetRef = useRef<HTMLDivElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -278,11 +282,13 @@ 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])
|
||||||
|
|
||||||
// Search users
|
// Load users list (for search view + @mentions in general chat)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== 'search' || !slug) return
|
if (!slug) return
|
||||||
api.users.list(slug).then(setAllUsers).catch(() => {/**/})
|
if (view === 'search' || (view === 'messages' && activeRoom?.type === 'general')) {
|
||||||
}, [view, slug])
|
if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/})
|
||||||
|
}
|
||||||
|
}, [view, slug, activeRoom?.type, allUsers.length])
|
||||||
|
|
||||||
// Search effect
|
// Search effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -335,12 +341,30 @@ export function ChatWidget() {
|
|||||||
|
|
||||||
const handleTextChange = (val: string) => {
|
const handleTextChange = (val: string) => {
|
||||||
setText(val)
|
setText(val)
|
||||||
|
// Detect @mention: find last @ before cursor position
|
||||||
|
const cursor = textareaRef.current?.selectionStart ?? val.length
|
||||||
|
const before = val.slice(0, cursor)
|
||||||
|
const match = before.match(/@([^\s@]*)$/)
|
||||||
|
setMentionQuery(match ? match[1] : null)
|
||||||
|
|
||||||
if (!val.trim()) { sendTyping(false); return }
|
if (!val.trim()) { sendTyping(false); return }
|
||||||
sendTyping(true)
|
sendTyping(true)
|
||||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||||
typingTimerRef.current = setTimeout(() => sendTyping(false), 4000)
|
typingTimerRef.current = setTimeout(() => sendTyping(false), 4000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const insertMention = (mentionUser: User) => {
|
||||||
|
const cursor = textareaRef.current?.selectionStart ?? text.length
|
||||||
|
const before = text.slice(0, cursor)
|
||||||
|
const match = before.match(/@([^\s@]*)$/)
|
||||||
|
if (!match) return
|
||||||
|
const start = cursor - match[0].length
|
||||||
|
const newText = text.slice(0, start) + `@${mentionUser.name} ` + text.slice(cursor)
|
||||||
|
setText(newText)
|
||||||
|
setMentionQuery(null)
|
||||||
|
setTimeout(() => textareaRef.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
// ── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const openRoomById = async (roomId: string) => {
|
const openRoomById = async (roomId: string) => {
|
||||||
@@ -378,7 +402,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, 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 })
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
} catch { /**/ }
|
} catch { /**/ }
|
||||||
}
|
}
|
||||||
@@ -399,7 +423,7 @@ export function ChatWidget() {
|
|||||||
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); setMentionQuery(null)
|
||||||
sendTyping(false)
|
sendTyping(false)
|
||||||
try {
|
try {
|
||||||
let url: string | undefined
|
let url: string | undefined
|
||||||
@@ -542,9 +566,20 @@ export function ChatWidget() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{view === 'rooms' && visibleRooms.length > 0 && <p className="text-xs text-white/70">{visibleRooms.length} чатов</p>}
|
{view === 'rooms' && visibleRooms.length > 0 && <p className="text-xs text-white/70">{visibleRooms.length} чатов</p>}
|
||||||
{view === 'messages' && activeRoom?.type === 'direct' && isOtherOnline(activeRoom) && (
|
{view === 'messages' && activeRoom?.type === 'direct' && (() => {
|
||||||
<p className="text-xs text-green-300">в сети</p>
|
const roleLabels: Record<string, string> = {
|
||||||
)}
|
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||||||
|
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
||||||
|
}
|
||||||
|
const roleLabel = activeRoom.otherUserRole ? roleLabels[activeRoom.otherUserRole] : null
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-white/70">
|
||||||
|
{isOtherOnline(activeRoom)
|
||||||
|
? <span className="text-green-300">в сети{roleLabel ? ` · ${roleLabel}` : ''}</span>
|
||||||
|
: roleLabel ?? ''}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
@@ -763,13 +798,32 @@ export function ChatWidget() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* @mention dropdown */}
|
||||||
|
{mentionQuery !== null && (() => {
|
||||||
|
const q = mentionQuery.toLowerCase()
|
||||||
|
const candidates = allUsers
|
||||||
|
.filter(u => u.id !== user?.id && u.name.toLowerCase().includes(q))
|
||||||
|
.slice(0, 6)
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="mb-1.5 bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-xl shadow-lg overflow-hidden">
|
||||||
|
{candidates.map(u => (
|
||||||
|
<button key={u.id} onMouseDown={e => { e.preventDefault(); insertMention(u) }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors text-left">
|
||||||
|
<Avatar name={u.name} size={22} />
|
||||||
|
<span className="text-sm text-slate-800 dark:text-slate-200 truncate">{u.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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()}
|
<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 => handleTextChange(e.target.value)} onKeyDown={handleKeyDown}
|
<textarea ref={textareaRef} 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}
|
||||||
@@ -777,7 +831,7 @@ export function ChatWidget() {
|
|||||||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 mt-1.5">Enter — отправить · Shift+Enter — перенос</p>
|
<p className="text-[10px] text-slate-400 mt-1.5">Enter — отправить · Shift+Enter — перенос · @ — упомянуть</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -947,13 +1001,16 @@ function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu,
|
|||||||
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||||||
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
||||||
}
|
}
|
||||||
|
const roleLabel = !isSystem && msg.senderRole ? roleLabels[msg.senderRole] : null
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline gap-1.5 mb-0.5 ml-1">
|
<div className="group/name relative inline-flex items-baseline gap-1 mb-0.5 ml-1 cursor-default">
|
||||||
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">
|
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">
|
||||||
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
|
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
|
||||||
</p>
|
</p>
|
||||||
{!isSystem && msg.senderRole && roleLabels[msg.senderRole] && (
|
{roleLabel && (
|
||||||
<p className="text-[9px] text-slate-400 dark:text-slate-500">{roleLabels[msg.senderRole]}</p>
|
<span className="hidden group-hover/name:inline text-[9px] text-slate-400 dark:text-slate-500 transition-all">
|
||||||
|
· {roleLabel}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1342,6 +1342,7 @@ export interface ChatRoom {
|
|||||||
lastMessageAt: string | null
|
lastMessageAt: string | null
|
||||||
lastSender: string | null
|
lastSender: string | null
|
||||||
otherUserName: string | null
|
otherUserName: string | null
|
||||||
|
otherUserRole: string | null
|
||||||
otherUserId: string | null
|
otherUserId: string | null
|
||||||
otherUserLastRead: string | null
|
otherUserLastRead: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user