fix: chat — 4 fixes for mentions, role display, header, close-on-outside

1. Role tooltip is now absolute-positioned (no layout impact on bubble width)
2. Outside-click uses overlay div instead of document mousedown (fixes mention dropdown closing chat)
3. Header: role shown in small white/50 text, separate from green 'в сети'
4. @mentions: highlighted in messages (yellow for self, bold for others); bypass mute if mentioned

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 17:09:31 +03:00
parent cf552d4b40
commit b91a75c89f

View File

@@ -105,6 +105,24 @@ interface ToastNotif {
id: string; roomId: string; roomName: string; senderName: string; text: string
}
function renderWithMentions(text: string, myName?: string) {
const parts = text.split(/(@\S+)/g)
if (parts.length === 1) return <>{text}</>
const myFirst = myName?.split(' ')[0]?.toLowerCase()
return <>
{parts.map((part, i) => {
if (!part.startsWith('@')) return <span key={i}>{part}</span>
const mentioned = part.slice(1).toLowerCase()
const isMe = myFirst && mentioned.startsWith(myFirst)
return (
<span key={i} className={cn('font-semibold', isMe ? 'bg-yellow-300/20 text-yellow-200 rounded px-0.5' : 'opacity-90')}>
{part}
</span>
)
})}
</>
}
// ── Main ───────────────────────────────────────────────────────────────────
type View = 'rooms' | 'messages' | 'search' | 'settings'
@@ -204,7 +222,10 @@ export function ChatWidget() {
if (curr > prev) {
const isViewing = open && view === 'messages' && activeRoomIdRef.current === room.id
const isMuted = mutedIds.includes(room.id)
if (!isViewing && !isMuted) {
// Bypass mute if current user is mentioned
const myFirst = (user as { name?: string } | null | undefined)?.name?.split(' ')[0]
const isMentioned = myFirst ? (room.lastMessage ?? '').includes(`@${myFirst}`) : false
if (!isViewing && (!isMuted || isMentioned)) {
if (settings.soundEnabled) playNotifSound()
if (settings.popupEnabled !== false) {
const rName = room.type === 'general' ? 'Общий чат'
@@ -318,17 +339,7 @@ export function ChatWidget() {
return () => window.removeEventListener('click', h)
}, [ctxMenu])
// Close chat when clicking outside the widget
useEffect(() => {
if (!open) return
const h = (e: MouseEvent) => {
if (widgetRef.current && !widgetRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [open])
// close-on-outside handled by overlay div in JSX
// ── Typing helpers ────────────────────────────────────────────────────────
@@ -530,6 +541,8 @@ export function ChatWidget() {
return (
<div ref={widgetRef}>
{/* Transparent overlay — closes chat when clicking outside panel */}
{open && <div className="fixed inset-0 z-40" onMouseDown={() => setOpen(false)} />}
{/* Float button */}
<button onClick={() => setOpen(v => !v)}
className={cn('fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg bg-brand-600 hover:bg-brand-700 text-white transition-all flex items-center justify-center', open && 'scale-90')}>
@@ -572,11 +585,13 @@ export function ChatWidget() {
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
}
const roleLabel = activeRoom.otherUserRole ? roleLabels[activeRoom.otherUserRole] : null
const online = isOtherOnline(activeRoom)
if (!online && !roleLabel) return null
return (
<p className="text-xs text-white/70">
{isOtherOnline(activeRoom)
? <span className="text-green-300">в сети{roleLabel ? ` · ${roleLabel}` : ''}</span>
: roleLabel ?? ''}
<p className="text-xs flex items-center gap-1">
{online && <span className="text-green-300">в сети</span>}
{online && roleLabel && <span className="text-white/40">·</span>}
{roleLabel && <span className="text-white/50 text-[10px]">{roleLabel}</span>}
</p>
)
})()}
@@ -759,6 +774,7 @@ export function ChatWidget() {
isEditing={editingMsgId === msg.id}
editText={editText}
isRead={isRead(msg)}
currentUserName={user?.name}
onContextMenu={e => {
if (msg.deletedAt || msg.isSystem || activeRoom?.type === 'notifications') return
e.preventDefault()
@@ -984,8 +1000,9 @@ function RoomRow({ room, isPinned, isMuted, isOnline, onClick, onMenuClick }: {
// ── MessageBubble ──────────────────────────────────────────────────────────
function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu, onEditTextChange, onSaveEdit, onCancelEdit, onReaction }: {
function MessageBubble({ msg, isOwn, isEditing, editText, isRead, currentUserName, onContextMenu, onEditTextChange, onSaveEdit, onCancelEdit, onReaction }: {
msg: ChatMessage; isOwn: boolean; isEditing: boolean; editText: string; isRead: boolean
currentUserName?: string
onContextMenu: (e: React.MouseEvent) => void
onEditTextChange: (v: string) => void
onSaveEdit: () => void; onCancelEdit: () => void
@@ -1003,13 +1020,13 @@ function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu,
}
const roleLabel = !isSystem && msg.senderRole ? roleLabels[msg.senderRole] : null
return (
<div className="group/name relative inline-flex items-baseline gap-1 mb-0.5 ml-1 cursor-default">
<div className="group/name relative inline-block mb-0.5 ml-1 cursor-default">
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
</p>
{roleLabel && (
<span className="hidden group-hover/name:inline text-[9px] text-slate-400 dark:text-slate-500 transition-all">
· {roleLabel}
<span className="absolute left-full top-0 ml-1.5 px-1.5 py-0.5 rounded bg-slate-700 dark:bg-slate-900 text-white text-[9px] whitespace-nowrap opacity-0 group-hover/name:opacity-100 transition-opacity z-10 pointer-events-none shadow-sm">
{roleLabel}
</span>
)}
</div>
@@ -1041,7 +1058,7 @@ function MessageBubble({ msg, isOwn, isEditing, editText, isRead, onContextMenu,
)}
{msg.text && (
<p className={cn('px-3 py-2', isSystem ? 'text-amber-900 dark:text-amber-200' : isOwn ? 'text-white' : 'text-slate-800 dark:text-slate-200')}>
{msg.text}
{renderWithMentions(msg.text, currentUserName)}
</p>
)}
</div>