Files
iistwin/client/src/pages/Chat.tsx
Ильяс Султанов 7672dd98df fix(chat): подавлять push-уведомления при открытом активном чате и автоотметка прочтения в task chat
- Добавлены active_chat_* поля в user_presence и endpoint /api/devices/active-chat

- Сервер не шлёт Web Push/Pushy, если получатель смотрит в тот же чат на видимой вкладке

- Клиент сообщает серверу и Service Worker об активном чате из Chat.tsx и TaskChat.tsx

- TaskChat: входящие сообщения автоматически отмечаются прочитанными при видимой вкладке

- SW: подавляет foreground/system push, если активный чат совпадает
2026-07-16 18:59:00 +03:00

2449 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useLocation, useParams } from 'wouter';
import { format, isToday, isYesterday } from 'date-fns';
import { apiRequest, queryClient } from '@/lib/queryClient';
import {
enqueueAttachment,
getAllAttachments,
registerAttachmentBackgroundSync,
dispatchAttachmentQueueUpdated,
} from '@/lib/attachmentQueue';
import { getAll as getAllQueued } from '@/lib/offlineQueue';
import { reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
import { useAuth } from '@/hooks/useAuth';
import { useEvents } from '@/hooks/useEvents';
import { useToast } from '@/hooks/use-toast';
import { Sidebar } from '@/components/Sidebar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarColor, getUserInitials } from '@/lib/avatar';
import { Badge } from '@/components/ui/badge';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
MessageSquare, Plus, Send, Reply, X, Edit, Check, MoreVertical,
Users, Trash2, UserPlus, AtSign, UserMinus, BarChart2, Link2, ExternalLink, Paperclip, Loader2,
Clock,
ChevronLeft, Bell, BellOff, Smile,
} from 'lucide-react';
import { isPWAInstalled } from '@/lib/pwa';
import { PendingAttachmentList, AttachmentDisplay } from '@/components/chat/ChatAttachments';
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
import { cn } from '@/lib/utils';
import { ReactionBar, type ReactionGroup } from '@/components/chat/ReactionBar';
import { PollCard, type PollData } from '@/components/chat/PollCard';
import { PollCreateDialog } from '@/components/chat/PollCreateDialog';
// ── Types ─────────────────────────────────────────────────────────────────────
interface ConvUser {
id: number;
firstName: string;
lastName: string;
middleName?: string;
email?: string | null;
position?: string;
lastReadAt?: string | null;
}
interface ConvBot {
id: number;
name: string;
avatarUrl?: string | null;
}
interface ConvMessage {
id: number;
conversationId: number;
message: string;
replyToId?: number | null;
mentionedUserIds?: number[] | null;
attachments?: ChatAttachment[] | null;
createdAt: string;
updatedAt: string;
isDeleted: boolean;
author: { id: number; firstName: string; lastName: string; middleName?: string } | null;
bot?: ConvBot | null;
replyTo?: { id: number; message: string; author: { id: number; firstName: string; lastName: string; middleName?: string } | null } | null;
pending?: boolean;
queueItemId?: string;
attachmentQueueItemId?: string;
}
interface Conversation {
id: number;
type: 'direct' | 'group' | 'bot_direct';
name?: string | null;
displayName: string;
organizationId: number;
createdBy: number;
createdAt: string;
updatedAt: string;
members: ConvUser[];
unreadCount: number;
lastMessage?: ConvMessage | null;
isExternal?: boolean;
isDefault?: boolean;
isMuted?: boolean;
bot?: ConvBot | null;
botId?: number | null;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function formatDateHeader(date: Date) {
if (isToday(date)) return 'Сегодня';
if (isYesterday(date)) return 'Вчера';
return format(date, 'dd.MM.yyyy');
}
function groupByDate(messages: ConvMessage[]) {
const groups: { dateKey: string; date: Date; messages: ConvMessage[] }[] = [];
for (const msg of messages) {
if (!msg.createdAt) continue;
const d = new Date(msg.createdAt);
if (isNaN(d.getTime())) continue;
const key = format(d, 'yyyy-MM-dd');
const last = groups[groups.length - 1];
if (last && last.dateKey === key) {
last.messages.push(msg);
} else {
groups.push({ dateKey: key, date: d, messages: [msg] });
}
}
return groups;
}
// ── Message bubble ─────────────────────────────────────────────────────────────
function MessageBubble({
msg, isOwn, onReply, onEdit, onDelete, reactions, poll, onReactionsChange, onPollChange,
onOpenUserChat, onOpenBotChat,
}: {
msg: ConvMessage;
isOwn: boolean;
onReply: (m: ConvMessage) => void;
onEdit: (m: ConvMessage) => void;
onDelete: (m: ConvMessage) => void;
reactions?: ReactionGroup[];
poll?: PollData;
onReactionsChange?: (r: ReactionGroup[]) => void;
onPollChange?: (p: PollData) => void;
onOpenUserChat?: (userId: number) => void;
onOpenBotChat?: (botId: number) => void;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const isEdited = msg.updatedAt && msg.updatedAt !== msg.createdAt;
if (msg.isDeleted) {
return (
<div className={cn("flex", isOwn ? "justify-end" : "justify-start")} data-msg-id={msg.id}>
<span className="text-xs text-muted-foreground italic px-3 py-1">Сообщение удалено</span>
</div>
);
}
return (
<div className={cn("flex group", isOwn ? "justify-end" : "justify-start")} data-msg-id={msg.id}>
{!isOwn && (
<Avatar className="w-6 h-6 mr-2 mt-1 shrink-0">
{msg.bot?.avatarUrl
? <img src={msg.bot.avatarUrl} alt={msg.bot.name} className="w-full h-full object-cover rounded-full" />
: (
<AvatarFallback className={cn("text-[9px]", msg.author ? `${getAvatarColor(msg.author.id)} text-white` : "")}>
{msg.bot
? <span className="text-[10px]">🤖</span>
: msg.author ? getUserInitials(msg.author.firstName, msg.author.lastName, msg.author.middleName) : '?'
}
</AvatarFallback>
)
}
</Avatar>
)}
<div className={cn("max-w-[85%] md:max-w-[70%] flex flex-col", isOwn ? "items-end" : "items-start")}>
{!isOwn && (
<span className="text-[10px] text-muted-foreground mb-0.5 px-1">
{msg.bot ? (
<button
className="hover:underline cursor-pointer text-left"
onClick={(e) => { e.stopPropagation(); onOpenBotChat?.(msg.bot!.id); }}
>
{msg.bot.name}
</button>
) : (
msg.author ? (
<button
className="hover:underline cursor-pointer text-left"
onClick={(e) => { e.stopPropagation(); onOpenUserChat?.(msg.author!.id); }}
>
{msg.author.firstName} {msg.author.middleName || ''} {msg.author.lastName}
</button>
) : ''
)}
</span>
)}
{msg.replyTo && (
<button
type="button"
className="mb-1 px-2 py-1 rounded border-l-2 border-primary bg-muted/50 text-xs text-muted-foreground max-w-full text-left w-full cursor-pointer hover:bg-muted transition-colors"
onClick={() => {
const el = document.querySelector(`[data-msg-id="${msg.replyTo!.id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
setTimeout(() => {
el.classList.remove('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
}, 2000);
}
}}
>
<span className="font-medium">{msg.replyTo.author?.firstName} {msg.replyTo.author?.middleName || ''} {msg.replyTo.author?.lastName}:</span>{' '}
<span className="line-clamp-1">{msg.replyTo.message}</span>
</button>
)}
<div className="flex items-end gap-1 min-w-0">
{isOwn && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5 mb-0.5 hidden md:flex">
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onReply(msg)}>
<Reply className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => onReactionsChange && setPickerOpen(v => !v)}
title="Реакция"
>
<Smile className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onEdit(msg)}>
<Edit className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5 text-destructive" onClick={() => onDelete(msg)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
<div
className={cn(
"rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap select-text max-w-full min-w-0 [overflow-wrap:anywhere]",
isOwn
? "bg-primary text-primary-foreground rounded-br-sm"
: "bg-muted rounded-bl-sm",
pickerOpen && "ring-2 ring-primary/40"
)}
>
{msg.message}
</div>
{!isOwn && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5 mb-0.5 hidden md:flex">
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onReply(msg)}>
<Reply className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => onReactionsChange && setPickerOpen(v => !v)}
title="Реакция"
>
<Smile className="h-3 w-3" />
</Button>
</div>
)}
</div>
{msg.attachments && msg.attachments.length > 0 && (
<AttachmentDisplay attachments={msg.attachments} isOwn={isOwn} />
)}
{/* Опрос */}
{poll && onPollChange && (
<PollCard poll={poll} onPollChange={onPollChange} isOwn={isOwn} />
)}
{/* Реакции — пикер открывается по клику на пузырёк */}
{onReactionsChange && (
<ReactionBar
messageId={msg.id}
messageType="conv"
reactions={reactions ?? []}
onReactionsChange={onReactionsChange}
className={isOwn ? "justify-end" : ""}
pickerOpen={pickerOpen}
onPickerOpenChange={setPickerOpen}
/>
)}
<span className="text-[10px] text-muted-foreground mt-0.5 px-1 flex items-center gap-1">
{msg.pending && (
<>
<Clock className="w-3 h-3" />
<span>отправка</span>
</>
)}
{format(new Date(msg.createdAt), 'HH:mm')}
{isEdited && ' · изменено'}
</span>
</div>
</div>
);
}
// ── Types for connections ──────────────────────────────────────────────────────
interface OrgConnectionItem {
id: number;
status: string;
isInitiator: boolean;
initiatorOrg: { id: number; displayName: string } | null;
partnerOrg: { id: number; displayName: string } | null;
}
interface ExternalUser {
id: number;
firstName: string;
lastName: string;
middleName?: string;
position?: string | null;
organizationId: number;
}
// ── New Conversation Dialog ────────────────────────────────────────────────────
function NewConversationDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onCreated: (conv: Conversation) => void;
}) {
const [tab, setTab] = useState<'internal' | 'external'>('internal');
const [mode, setMode] = useState<'direct' | 'group'>('direct');
const [groupName, setGroupName] = useState('');
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// External state
const [selectedConnId, setSelectedConnId] = useState<number | null>(null);
const [extSearch, setExtSearch] = useState('');
const [extSelectedIds, setExtSelectedIds] = useState<number[]>([]);
const [extGroupName, setExtGroupName] = useState('');
const { toast } = useToast();
// Internal users
const { data: usersData } = useQuery<{ success: boolean; users: ConvUser[] }>({
queryKey: ['/api/users/list'],
enabled: open && tab === 'internal',
});
const allUsers = usersData?.users ?? [];
const filtered = useMemo(() => {
const list = allUsers.filter(u =>
!search || `${u.firstName} ${u.middleName || ''} ${u.lastName} ${u.email}`.toLowerCase().includes(search.toLowerCase())
);
list.sort((a, b) => {
const aSel = selectedIds.includes(a.id);
const bSel = selectedIds.includes(b.id);
if (aSel === bSel) return 0;
return aSel ? -1 : 1;
});
return list;
}, [allUsers, search, selectedIds]);
// Connections list — uses non-admin endpoint (active only, no tokens)
const { data: connData } = useQuery<{ success: boolean; connections: OrgConnectionItem[] }>({
queryKey: ['/api/org-connections/active'],
enabled: open && tab === 'external',
});
const activeConns = (connData?.connections ?? []).filter(c => c.status === 'active');
// External users for selected connection
const { data: extUsersData, isLoading: extUsersLoading } = useQuery<{ success: boolean; users: ExternalUser[]; partnerOrgId: number }>({
queryKey: ['/api/org-connections', selectedConnId, 'users'],
enabled: open && tab === 'external' && !!selectedConnId,
queryFn: async () => {
const res = await apiRequest('GET', `/api/org-connections/${selectedConnId}/users`);
return res.json();
},
});
const extUsers = useMemo(() => {
const list = (extUsersData?.users ?? []).filter(u =>
!extSearch || `${u.firstName} ${u.middleName || ''} ${u.lastName}`.toLowerCase().includes(extSearch.toLowerCase())
);
list.sort((a, b) => {
const aSel = extSelectedIds.includes(a.id);
const bSel = extSelectedIds.includes(b.id);
if (aSel === bSel) return 0;
return aSel ? -1 : 1;
});
return list;
}, [extUsersData, extSearch, extSelectedIds]);
const selectedConn = activeConns.find(c => c.id === selectedConnId);
const reset = () => {
setSelectedIds([]);
setGroupName('');
setSearch('');
setExtSelectedIds([]);
setExtGroupName('');
setExtSearch('');
setSelectedConnId(null);
};
const createMutation = useMutation({
mutationFn: async () => {
if (tab === 'internal') {
if (mode === 'direct') {
if (selectedIds.length !== 1) throw new Error('Выберите одного пользователя');
const res = await apiRequest('POST', '/api/messenger/conversations', {
type: 'direct',
targetUserId: selectedIds[0],
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
} else {
if (!groupName.trim()) throw new Error('Введите название чата');
if (selectedIds.length === 0) throw new Error('Добавьте хотя бы одного участника');
const res = await apiRequest('POST', '/api/messenger/conversations', {
type: 'group',
name: groupName.trim(),
memberIds: selectedIds,
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
}
} else {
// External
if (!selectedConnId) throw new Error('Выберите подключение');
if (mode === 'direct') {
if (extSelectedIds.length !== 1) throw new Error('Выберите одного пользователя');
const res = await apiRequest('POST', '/api/messenger/conversations', {
type: 'external_direct',
connectionId: selectedConnId,
targetUserId: extSelectedIds[0],
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
} else {
if (!extGroupName.trim()) throw new Error('Введите название группы');
if (extSelectedIds.length === 0) throw new Error('Добавьте хотя бы одного участника');
const res = await apiRequest('POST', '/api/messenger/conversations', {
type: 'external_group',
connectionId: selectedConnId,
name: extGroupName.trim(),
memberIds: extSelectedIds,
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
}
}
},
onSuccess: (conv) => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
onCreated(conv);
onOpenChange(false);
reset();
},
onError: (err: any) => {
toast({ variant: 'destructive', title: err.message });
},
});
const toggleUser = useCallback((id: number) => {
if (mode === 'direct') {
// Автопереключение в групповой режим при выборе второго пользователя
if (selectedIds.length === 1 && selectedIds[0] !== id) {
setMode('group');
setSelectedIds([selectedIds[0], id]);
} else {
setSelectedIds([id]);
}
} else {
setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
}
}, [mode, selectedIds]);
const toggleExtUser = useCallback((id: number) => {
if (mode === 'direct') {
// Автопереключение в групповой режим при выборе второго пользователя
if (extSelectedIds.length === 1 && extSelectedIds[0] !== id) {
setMode('group');
setExtSelectedIds([extSelectedIds[0], id]);
} else {
setExtSelectedIds([id]);
}
} else {
setExtSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
}
}, [mode, extSelectedIds]);
const getConnName = (conn: OrgConnectionItem) => {
const partner = conn.isInitiator ? conn.partnerOrg : conn.initiatorOrg;
return partner?.displayName ?? 'Партнёр';
};
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) reset(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Новый чат</DialogTitle>
</DialogHeader>
{/* Tab: внутренний / внешний */}
<div className="flex gap-1 mb-3 border border-border rounded p-0.5 bg-muted/30">
<button
className={cn(
"flex-1 flex items-center justify-center gap-1.5 text-xs py-1 rounded transition-colors",
tab === 'internal' ? "bg-background shadow-sm font-medium" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => { setTab('internal'); reset(); }}
>
<Users className="w-3.5 h-3.5" />
Моя организация
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-1.5 text-xs py-1 rounded transition-colors",
tab === 'external' ? "bg-background shadow-sm font-medium" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => { setTab('external'); reset(); }}
>
<Link2 className="w-3.5 h-3.5" />
Внешние контакты
</button>
</div>
{/* Тип чата */}
<div className="flex items-center gap-2 mb-3">
<div className="flex gap-2">
<Button
size="sm"
variant={mode === 'direct' ? 'default' : 'outline'}
className="h-7 px-3 text-xs"
onClick={() => { setMode('direct'); setSelectedIds([]); setExtSelectedIds([]); }}
>
Личный
</Button>
<Button
size="sm"
variant={mode === 'group' ? 'default' : 'outline'}
className="h-7 px-3 text-xs"
onClick={() => { setMode('group'); setSelectedIds([]); setExtSelectedIds([]); }}
>
<Users className="w-3.5 h-3.5 mr-1.5" />
Группа
</Button>
</div>
<Badge variant={mode === 'group' ? 'default' : 'secondary'} className="text-[10px] h-5 px-1.5">
{mode === 'group' ? 'Групповой чат' : 'Личный чат'}
</Badge>
</div>
{/* ── Internal ── */}
{tab === 'internal' && (
<>
{mode === 'group' && (
<Input
placeholder="Название группы"
value={groupName}
onChange={e => setGroupName(e.target.value)}
className="mb-2 h-8 text-sm"
/>
)}
<Input
placeholder="Поиск пользователей..."
value={search}
onChange={e => setSearch(e.target.value)}
className="mb-2 h-8 text-sm"
/>
<ScrollArea className="h-52 border rounded-md">
<div className="p-1 space-y-0.5">
{filtered.map(u => (
<div
key={u.id}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted transition-colors",
selectedIds.includes(u.id) && "bg-primary/10"
)}
onClick={() => toggleUser(u.id)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className={cn(getAvatarColor(u.id), "text-white text-[8px]")}>
{getUserInitials(u.firstName, u.lastName, u.middleName)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{u.firstName} {u.middleName || ''} {u.lastName}</div>
{u.position && <div className="text-[10px] text-muted-foreground truncate">{u.position}</div>}
</div>
{selectedIds.includes(u.id) && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
</div>
))}
{filtered.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">Пользователи не найдены</div>
)}
</div>
</ScrollArea>
</>
)}
{/* ── External ── */}
{tab === 'external' && (
<>
{activeConns.length === 0 ? (
<div className="border border-border rounded p-4 text-center space-y-2">
<Link2 className="w-6 h-6 mx-auto text-muted-foreground opacity-50" />
<p className="text-xs text-muted-foreground">
Нет активных подключений. Настройте кросс-организационное подключение в разделе <strong>Настройки → Подключения</strong>.
</p>
</div>
) : (
<>
{/* Выбор подключения */}
{!selectedConnId ? (
<div className="space-y-1.5">
<p className="text-[11px] text-muted-foreground">Выберите партнёрскую организацию:</p>
{activeConns.map(conn => (
<div
key={conn.id}
className="flex items-center gap-2 px-3 py-2 rounded border border-border cursor-pointer hover:bg-muted transition-colors"
onClick={() => setSelectedConnId(conn.id)}
>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium">{getConnName(conn)}</span>
</div>
))}
</div>
) : (
<>
{/* Заголовок партнёра + кнопка назад */}
<div className="flex items-center gap-2 mb-2">
<button
className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => { setSelectedConnId(null); setExtSelectedIds([]); }}
>
← Назад
</button>
<span className="text-[11px] font-medium">{selectedConn ? getConnName(selectedConn) : ''}</span>
</div>
{mode === 'group' && (
<Input
placeholder="Название группы"
value={extGroupName}
onChange={e => setExtGroupName(e.target.value)}
className="mb-2 h-8 text-sm"
/>
)}
<Input
placeholder="Поиск пользователей..."
value={extSearch}
onChange={e => setExtSearch(e.target.value)}
className="mb-2 h-8 text-sm"
/>
<ScrollArea className="h-44 border rounded-md">
<div className="p-1 space-y-0.5">
{extUsersLoading && (
<div className="text-xs text-muted-foreground text-center py-4">Загрузка...</div>
)}
{!extUsersLoading && extUsers.map(u => (
<div
key={u.id}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted transition-colors",
extSelectedIds.includes(u.id) && "bg-primary/10"
)}
onClick={() => toggleExtUser(u.id)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className="text-[9px]">
{getUserInitials(u.firstName, u.lastName, u.middleName)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{u.firstName} {u.middleName || ''} {u.lastName}</div>
{u.position && <div className="text-[10px] text-muted-foreground truncate">{u.position}</div>}
</div>
{extSelectedIds.includes(u.id) && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
</div>
))}
{!extUsersLoading && extUsers.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">Нет пользователей</div>
)}
</div>
</ScrollArea>
</>
)}
</>
)}
</>
)}
<DialogFooter>
<Button
size="sm"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Создание...' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── Group Members Dialog (unified: view + add) ─────────────────────────────────
function GroupMembersDialog({
open,
onOpenChange,
conversation,
currentUserId,
currentUserRole,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
conversation: Conversation;
currentUserId: number;
currentUserRole: string;
}) {
const { toast } = useToast();
const [addSearch, setAddSearch] = useState('');
const [showAdd, setShowAdd] = useState(false);
// Определяем, является ли текущий пользователь "администратором группы"
// Для «Наша Фирма» (isDefault) — только org admin; для остальных групп — создатель или org admin
const isGroupAdmin = conversation.isDefault
? currentUserRole === 'admin'
: (conversation.createdBy === currentUserId || currentUserRole === 'admin');
const canLeave = !conversation.isDefault && conversation.createdBy !== currentUserId;
const canDeleteGroup = isGroupAdmin && !conversation.isDefault;
// Список пользователей для добавления
const { data: usersData } = useQuery<{ success: boolean; users: ConvUser[] }>({
queryKey: ['/api/users/list'],
enabled: open && isGroupAdmin && showAdd,
});
const existingIds = new Set(conversation.members.map(m => m.id));
const availableToAdd = (usersData?.users ?? [])
.filter(u => !existingIds.has(u.id))
.filter(u => !addSearch || `${u.firstName} ${u.middleName || ''} ${u.lastName}`.toLowerCase().includes(addSearch.toLowerCase()));
const addMutation = useMutation({
mutationFn: async (targetUserId: number) => {
const res = await apiRequest('POST', `/api/messenger/conversations/${conversation.id}/members`, { targetUserId });
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
setShowAdd(false);
setAddSearch('');
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const removeMutation = useMutation({
mutationFn: async (memberId: number) => {
const res = await apiRequest('DELETE', `/api/messenger/conversations/${conversation.id}/members/${memberId}`);
const data = await res.json();
if (!data.success) throw new Error(data.error);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
if (removeMutation.variables === currentUserId) onOpenChange(false);
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const deleteGroupMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest('DELETE', `/api/messenger/conversations/${conversation.id}`);
const data = await res.json();
if (!data.success) throw new Error(data.error);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
onOpenChange(false);
toast({ title: 'Группа удалена' });
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) { setShowAdd(false); setAddSearch(''); } }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{conversation.displayName} · участники</DialogTitle>
</DialogHeader>
{/* Список участников */}
<ScrollArea className="h-56 border rounded-md">
<div className="p-1 space-y-0.5">
{conversation.members.map(m => {
const isMe = m.id === currentUserId;
const isCreatorTag = conversation.createdBy === m.id && !conversation.isDefault;
const canRemove = isGroupAdmin && m.id !== currentUserId;
return (
<div key={m.id} className="flex items-center gap-2 px-2 py-1.5 rounded">
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className={cn(getAvatarColor(m.id), "text-white text-[8px]")}>{getUserInitials(m.firstName, m.lastName, m.middleName)}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{m.firstName} {m.middleName || ''} {m.lastName}
{isMe && <span className="text-muted-foreground font-normal"> (я)</span>}
{isCreatorTag && <span className="text-muted-foreground font-normal"> · создатель</span>}
</div>
{m.position && <div className="text-[10px] text-muted-foreground truncate">{m.position}</div>}
</div>
{canRemove && (
<Button
size="icon" variant="ghost"
className="h-5 w-5 text-destructive shrink-0"
onClick={() => removeMutation.mutate(m.id)}
disabled={removeMutation.isPending}
>
<UserMinus className="h-3 w-3" />
</Button>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Добавить участника (только для группового администратора) */}
{isGroupAdmin && (
<>
{!showAdd ? (
<Button variant="outline" size="sm" className="w-full" onClick={() => setShowAdd(true)}>
<UserPlus className="w-3.5 h-3.5 mr-1.5" />
Добавить участника
</Button>
) : (
<div className="space-y-2">
<Input
autoFocus
placeholder="Поиск пользователя..."
value={addSearch}
onChange={e => setAddSearch(e.target.value)}
className="h-8 text-xs"
/>
<ScrollArea className="h-36 border rounded-md">
<div className="p-1 space-y-0.5">
{availableToAdd.map(u => (
<div
key={u.id}
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted"
onClick={() => addMutation.mutate(u.id)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className={cn(getAvatarColor(u.id), "text-white text-[8px]")}>{getUserInitials(u.firstName, u.lastName, u.middleName)}</AvatarFallback>
</Avatar>
<span className="text-xs">{u.firstName} {u.middleName || ''} {u.lastName}</span>
</div>
))}
{availableToAdd.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">Нет доступных пользователей</div>
)}
</div>
</ScrollArea>
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => { setShowAdd(false); setAddSearch(''); }}>
Отмена
</Button>
</div>
)}
</>
)}
{/* Удалить группу (только для создателя или админа) */}
{canDeleteGroup && (
<Button
variant="destructive" size="sm" className="w-full"
onClick={() => {
if (confirm('Удалить группу навсегда? Это действие нельзя отменить.')) {
deleteGroupMutation.mutate();
}
}}
disabled={deleteGroupMutation.isPending}
>
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
Удалить группу
</Button>
)}
{/* Покинуть группу (только для не-создателей, не «Наша Фирма») */}
{canLeave && (
<Button
variant="destructive" size="sm" className="w-full"
onClick={() => removeMutation.mutate(currentUserId)}
disabled={removeMutation.isPending}
>
Покинуть группу
</Button>
)}
</DialogContent>
</Dialog>
);
}
// ── Highlight matching substring in search results ────────────────────────────
function HighlightMatch({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <>{text}</>;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark className="bg-yellow-200 dark:bg-yellow-700 text-inherit rounded-sm px-0">{text.slice(idx, idx + query.length)}</mark>
{text.slice(idx + query.length)}
</>
);
}
// ── Main Chat Page ────────────────────────────────────────────────────────────
export default function ChatPage() {
const { user } = useAuth();
const { toast } = useToast();
const [location, setLocation] = useLocation();
const params = useParams<{ conversationId?: string }>();
const [activeConvId, setActiveConvId] = useState<number | null>(null);
const [newConvOpen, setNewConvOpen] = useState(false);
const [groupMembersOpen, setGroupMembersOpen] = useState(false);
const [replyTo, setReplyTo] = useState<ConvMessage | null>(null);
const [editingMsg, setEditingMsg] = useState<ConvMessage | null>(null);
const [messageText, setMessageText] = useState('');
const [editText, setEditText] = useState('');
const [mentionedUserIds, setMentionedUserIds] = useState<number[]>([]);
const [mentionSearch, setMentionSearch] = useState<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Reactions & polls
const [reactionsMap, setReactionsMap] = useState<Record<number, ReactionGroup[]>>({});
const [pollsMap, setPollsMap] = useState<Record<number, PollData>>({});
const [pollDialogOpen, setPollDialogOpen] = useState(false);
// Search
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
// File attachments
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
// Tracks IndexedDB queue item IDs that are still pending. Used by the select
// function to keep optimistic messages after server refetches.
const activeQueueIdsRef = useRef<Set<string>>(new Set());
const activeAttachmentIdsRef = useRef<Set<string>>(new Set());
// Pagination for loading older messages
const [olderMessages, setOlderMessages] = useState<ConvMessage[]>([]);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Мобильный / PWA режим
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768 || isPWAInstalled());
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)');
const update = () => setIsMobile(mq.matches || isPWAInstalled());
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
// Debounce поиска
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchQuery), 200);
return () => clearTimeout(t);
}, [searchQuery]);
// ── Queries ──────────────────────────────────────────────────────────────────
const { data: searchData } = useQuery<{
success: boolean;
people: { id: number; firstName: string; lastName: string; middleName?: string; position?: string | null }[];
groups: { id: number; name: string }[];
external: { id: number; firstName: string; lastName: string; middleName?: string; position?: string | null; organizationId: number; connectionId: number }[];
bots: { id: number; name: string; login: string; avatarUrl?: string | null }[];
}>({
queryKey: ['/api/messenger/search', debouncedSearch],
queryFn: async () => {
if (!debouncedSearch.trim()) return { success: true, people: [], groups: [], external: [], bots: [] };
const res = await apiRequest('GET', `/api/messenger/search?q=${encodeURIComponent(debouncedSearch)}`);
return res.json();
},
enabled: debouncedSearch.trim().length > 0,
staleTime: 10000,
});
const searchResults = (searchQuery.trim() && debouncedSearch.trim()) ? searchData : null;
const { data: convsData, isLoading: convsLoading } = useQuery<{ success: boolean; conversations: Conversation[] }>({
queryKey: ['/api/messenger/conversations'],
refetchInterval: false,
});
const conversations = convsData?.conversations ?? [];
// Автооткрытие чата при переходе по push deep-link (/chat/:conversationId)
useEffect(() => {
if (!params?.conversationId) return;
const targetId = parseInt(params.conversationId, 10);
if (isNaN(targetId)) return;
setActiveConvId(targetId);
if (isMobile) setMobileView('chat');
}, [params?.conversationId, conversations.length]);
// Auto-mark read when a conversation is opened (including from URL deep-link)
useEffect(() => {
if (!activeConvId) return;
markReadMutation.mutate(activeConvId);
}, [activeConvId]);
// Auto-mark read when user returns to the tab/window with an open conversation
useEffect(() => {
if (!activeConvId) return;
const handleVisibility = () => {
if (document.visibilityState === 'visible') {
markReadMutation.mutate(activeConvId);
}
};
const handleFocus = () => {
markReadMutation.mutate(activeConvId);
};
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', handleFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', handleFocus);
};
}, [activeConvId]);
// Report active chat to server and SW so push is suppressed while this chat is open.
useEffect(() => {
if (!activeConvId) return;
const report = (visible: boolean) => {
reportActiveChat('messenger', activeConvId, visible);
setActiveChatInSw('messenger', activeConvId, visible);
};
report(document.visibilityState === 'visible');
const handleVisibility = () => {
report(document.visibilityState === 'visible');
};
document.addEventListener('visibilitychange', handleVisibility);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
clearActiveChat();
clearActiveChatInSw();
};
}, [activeConvId]);
const activeConv = conversations.find(c => c.id === activeConvId) ?? null;
const { data: msgsData, isLoading: msgsLoading } = useQuery<{ success: boolean; messages: ConvMessage[] }>({
queryKey: ['/api/messenger/conversations', activeConvId, 'messages'],
enabled: !!activeConvId,
refetchInterval: false,
// Reconciliation: merge optimistic pending messages back after server refetches
select: (data) => {
if (!data) return data;
const currentData = queryClient.getQueryData(['/api/messenger/conversations', activeConvId, 'messages']) as any;
if (!currentData?.messages) return data;
const optimistic = currentData.messages.filter((msg: ConvMessage) => msg.id < 0);
if (optimistic.length === 0) return data;
const serverIds = new Set(data.messages.map((m: ConvMessage) => m.id));
const activeQueueIds = activeQueueIdsRef.current;
const activeAttachmentIds = activeAttachmentIdsRef.current;
const uniqueOptimistic = optimistic.filter((msg: ConvMessage) => {
if (serverIds.has(msg.id)) return false;
if (msg.queueItemId) return activeQueueIds.has(msg.queueItemId);
if (msg.attachmentQueueItemId) return activeAttachmentIds.has(msg.attachmentQueueItemId);
return true;
});
if (uniqueOptimistic.length === 0) return data;
return { ...data, messages: [...data.messages, ...uniqueOptimistic] };
},
});
const initialMessages = msgsData?.messages ?? [];
// Reset pagination state on conversation switch
useEffect(() => {
setOlderMessages([]);
setHasMore(false);
}, [activeConvId]);
// Track whether there are more messages to load (50 is the default page size).
// Only update from the initial batch when no older pages have been loaded yet —
// once the user starts paginating, hasMore is exclusively controlled by loadMoreMessages.
useEffect(() => {
if (!msgsLoading && msgsData?.messages && olderMessages.length === 0) {
setHasMore(msgsData.messages.length >= 50);
}
}, [msgsData, msgsLoading]); // intentionally omit olderMessages to avoid loop
// Combined messages: older (paginated) prepended before the initial batch
const messages = [...olderMessages, ...initialMessages];
// Keep active queue ID refs in sync with IndexedDB. This lets the select
// function below preserve optimistic messages after server refetches.
useEffect(() => {
const refreshActiveIds = async () => {
try {
const [queued, attachments] = await Promise.all([
getAllQueued(),
getAllAttachments(),
]);
activeQueueIdsRef.current = new Set(queued.map(i => i.id));
activeAttachmentIdsRef.current = new Set(attachments.map(i => i.id));
// Proactively remove stale pending messages from this conversation's cache
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
(old: any) => {
if (!old?.messages) return old;
const cleaned = old.messages.filter((msg: ConvMessage) => {
if (msg.id >= 0) return true;
if (msg.queueItemId) return activeQueueIdsRef.current.has(msg.queueItemId);
if (msg.attachmentQueueItemId) return activeAttachmentIdsRef.current.has(msg.attachmentQueueItemId);
return true;
});
if (cleaned.length === old.messages.length) return old;
return { ...old, messages: cleaned };
}
);
} catch {
// IndexedDB not available
}
};
refreshActiveIds();
window.addEventListener('offline-queue-updated', refreshActiveIds);
window.addEventListener('attachment-queue-updated', refreshActiveIds);
return () => {
window.removeEventListener('offline-queue-updated', refreshActiveIds);
window.removeEventListener('attachment-queue-updated', refreshActiveIds);
};
}, [activeConvId]);
// Hydrate pending attachment messages from IndexedDB on mount
useEffect(() => {
if (!user || !activeConvId) return;
const hydrateFromAttachmentQueue = async () => {
try {
const items = await getAllAttachments();
const chatItems = items.filter(
item => item.type === 'messenger' && item.targetId === activeConvId
);
if (chatItems.length === 0) return;
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
(old: any) => {
const currentMessages: ConvMessage[] = old?.messages ?? [];
const existingIds = new Set(
currentMessages.filter(m => m.attachmentQueueItemId).map(m => m.attachmentQueueItemId!)
);
const pendingMessages: ConvMessage[] = [];
chatItems.forEach((item, index) => {
if (existingIds.has(item.id)) return;
const stableId = -((item.enqueuedAt % 900000) + (index + 1) * 1000 + 1);
const pendingMsg: ConvMessage = {
id: stableId,
conversationId: activeConvId,
message: item.message,
replyToId: item.replyToId || item.replyToMessageId || null,
mentionedUserIds: item.mentionedUserIds || null,
attachments: item.files.map(f => ({
name: f.name,
size: f.blob.size,
mimeType: f.type || undefined,
url: '',
})),
createdAt: new Date(item.enqueuedAt).toISOString(),
updatedAt: new Date(item.enqueuedAt).toISOString(),
isDeleted: false,
author: { id: user.id, firstName: user.firstName, lastName: user.lastName, middleName: (user as any).middleName },
replyTo: replyTo ? { id: replyTo.id, message: replyTo.message, author: replyTo.author } : null,
pending: true,
attachmentQueueItemId: item.id,
};
pendingMessages.push(pendingMsg);
});
if (pendingMessages.length === 0) return old;
return {
...(old ?? {}),
messages: [...currentMessages, ...pendingMessages],
};
}
);
} catch {
// IndexedDB not available
}
};
hydrateFromAttachmentQueue();
}, [activeConvId, user]);
// Remove completed attachment messages from optimistic cache
useEffect(() => {
if (!user || !activeConvId) return;
const refreshAttachmentIds = async () => {
try {
const items = await getAllAttachments();
const activeIds = new Set(items.map(i => i.id));
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
(old: any) => {
if (!old?.messages) return old;
const cleaned = old.messages.filter((msg: ConvMessage) => {
if (msg.id >= 0) return true;
if (!msg.attachmentQueueItemId) return true;
return activeIds.has(msg.attachmentQueueItemId);
});
if (cleaned.length === old.messages.length) return old;
return { ...old, messages: cleaned };
}
);
} catch {
// IndexedDB not available
}
};
refreshAttachmentIds();
window.addEventListener('attachment-queue-updated', refreshAttachmentIds);
return () => window.removeEventListener('attachment-queue-updated', refreshAttachmentIds);
}, [activeConvId, user]);
const loadMoreMessages = async () => {
if (!activeConvId || isLoadingMore || !hasMore) return;
const oldestId = olderMessages.length > 0 ? olderMessages[0].id : (initialMessages[0]?.id ?? null);
if (!oldestId) return;
// Capture scroll position before prepending to preserve viewport after DOM update
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
const prevScrollHeight = viewport?.scrollHeight ?? 0;
const prevScrollTop = viewport?.scrollTop ?? 0;
setIsLoadingMore(true);
try {
const res = await apiRequest('GET', `/api/messenger/conversations/${activeConvId}/messages?beforeId=${oldestId}&limit=50`);
const data = await res.json();
if (data.success && Array.isArray(data.messages)) {
setOlderMessages(prev => [...data.messages, ...prev]);
setHasMore(data.messages.length >= 50);
// Restore scroll position so the user stays at the same spot after prepend
requestAnimationFrame(() => {
const vp = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
if (vp) {
vp.scrollTop = prevScrollTop + (vp.scrollHeight - prevScrollHeight);
}
});
}
} catch {
// ignore
} finally {
setIsLoadingMore(false);
}
};
// ── Reactions & polls queries ─────────────────────────────────────────────────
useQuery({
queryKey: ['/api/messenger/conversations', activeConvId, 'reactions'],
enabled: !!activeConvId && messages.length > 0,
staleTime: 30000,
refetchInterval: false,
queryFn: async () => {
const res = await apiRequest('GET', `/api/messenger/conversations/${activeConvId}/reactions`);
const data = await res.json();
if (data.success) setReactionsMap(data.reactions ?? {});
return data;
},
});
useQuery({
queryKey: ['/api/messenger/conversations', activeConvId, 'polls'],
enabled: !!activeConvId && messages.length > 0,
staleTime: 30000,
refetchInterval: false,
queryFn: async () => {
const res = await apiRequest('GET', `/api/messenger/conversations/${activeConvId}/polls`);
const data = await res.json();
if (data.success) setPollsMap(data.polls ?? {});
return data;
},
});
// ── SSE ───────────────────────────────────────────────────────────────────────
useEvents({
onConvMessageCreated: useCallback((data: { conversationId: number; message: ConvMessage }) => {
const { conversationId, message } = data;
const isActiveConv = conversationId === activeConvId;
const isMine = message.author?.id === user?.id;
const isVisible = typeof document !== 'undefined' && document.visibilityState === 'visible';
// 1. Сначала добавляем сообщение в кэш активного чата, чтобы оно появилось
// одновременно со значком, а не после него.
queryClient.setQueryData(
['/api/messenger/conversations', conversationId, 'messages'],
(old: any) => {
if (!old) return { success: true, messages: [message] };
const exists = old.messages.some((m: ConvMessage) => m.id === message.id);
if (exists) return old;
return { ...old, messages: [...old.messages, message] };
}
);
// 2. Обновляем список чатов: bump наверх, lastMessage, unreadCount.
// Для активного и видимого чата сразу сбрасываем unreadCount в 0.
queryClient.setQueryData(
['/api/messenger/conversations'],
(old: any) => {
if (!old) {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
return old;
}
const exists = old.conversations.some((c: Conversation) => c.id === conversationId);
if (!exists) {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
return old;
}
const updated = old.conversations.map((c: Conversation) => {
if (c.id !== conversationId) return c;
return {
...c,
lastMessage: message,
updatedAt: message.createdAt,
unreadCount: (isActiveConv && isVisible) || isMine ? 0 : c.unreadCount + 1,
};
});
// Bump updated conversation to top (sort by updatedAt desc)
const sorted = [...updated].sort((a: Conversation, b: Conversation) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
return { ...old, conversations: sorted };
}
);
// 3. Автоотметка прочтения только для активного, видимого чата и чужих сообщений.
// Используем mutation, чтобы onSuccess обновил кэш и сбросил бейджи.
if (isActiveConv && !isMine && isVisible) {
markReadMutation.mutate(conversationId);
}
}, [activeConvId, user]),
onConvMessageUpdated: useCallback((data: { conversationId: number; message: ConvMessage }) => {
queryClient.setQueryData(
['/api/messenger/conversations', data.conversationId, 'messages'],
(old: any) => {
if (!old) return old;
return {
...old,
messages: old.messages.map((m: ConvMessage) =>
m.id === data.message.id ? data.message : m
),
};
}
);
}, []),
onConvMessageDeleted: useCallback((data: { conversationId: number; messageId: number }) => {
queryClient.setQueryData(
['/api/messenger/conversations', data.conversationId, 'messages'],
(old: any) => {
if (!old) return old;
return {
...old,
messages: old.messages.map((m: ConvMessage) =>
m.id === data.messageId ? { ...m, isDeleted: true } : m
),
};
}
);
}, []),
onConvRead: useCallback((data: { conversationId: number }) => {
queryClient.setQueryData(
['/api/messenger/conversations'],
(old: any) => {
if (!old) return old;
return {
...old,
conversations: old.conversations.map((c: Conversation) =>
c.id === data.conversationId ? { ...c, unreadCount: 0 } : c
),
};
}
);
}, []),
onConvCreated: useCallback((conv: Conversation) => {
queryClient.setQueryData(
['/api/messenger/conversations'],
(old: any) => {
if (!old) return { success: true, conversations: [conv] };
const exists = old.conversations.some((c: Conversation) => c.id === conv.id);
if (exists) return old;
return { ...old, conversations: [conv, ...old.conversations] };
}
);
}, []),
onReactionUpdated: useCallback((data: { taskMessageId?: number; convMessageId?: number; conversationId?: number; reactions: ReactionGroup[] }) => {
if (data.convMessageId != null) {
setReactionsMap(prev => ({ ...prev, [data.convMessageId!]: data.reactions }));
}
}, []),
onPollUpdated: useCallback((data: { taskMessageId?: number; convMessageId?: number; conversationId?: number; poll: PollData }) => {
if (data.convMessageId != null) {
setPollsMap(prev => ({ ...prev, [data.convMessageId!]: data.poll }));
}
}, []),
});
// Lightweight polling fallback: если SSE не доставил событие, раз в 10 секунд
// запрашиваем только новые сообщения после последнего известного id.
useEffect(() => {
if (!activeConvId || !user) return;
const interval = setInterval(async () => {
if (document.hidden) return;
const currentData = queryClient.getQueryData(['/api/messenger/conversations', activeConvId, 'messages']) as any;
const currentMessages: ConvMessage[] = currentData?.messages || [];
const lastServerMessage = currentMessages.filter((m) => m.id > 0).pop();
if (!lastServerMessage) return;
try {
const res = await apiRequest('GET', `/api/messenger/conversations/${activeConvId}/messages?afterId=${lastServerMessage.id}&limit=50`);
const data = await res.json();
if (!data.success || !Array.isArray(data.messages) || data.messages.length === 0) return;
queryClient.setQueryData(['/api/messenger/conversations', activeConvId, 'messages'], (old: any) => {
if (!old) return { success: true, messages: data.messages };
const existingIds = new Set(old.messages.map((m: ConvMessage) => m.id));
const merged = [...old.messages, ...data.messages.filter((m: ConvMessage) => !existingIds.has(m.id))];
return { ...old, messages: merged };
});
} catch {
// ignore polling errors
}
}, 10000);
return () => clearInterval(interval);
}, [activeConvId, user]);
// ── Auto-scroll ───────────────────────────────────────────────────────────────
// Scroll to bottom whenever the active conversation changes (open from cache, new chat, etc.)
useEffect(() => {
if (!activeConvId) return;
requestAnimationFrame(() => {
setTimeout(() => {
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
if (viewport) viewport.scrollTop = viewport.scrollHeight;
}, 120);
});
}, [activeConvId]);
// Scroll to bottom when new messages arrive
// (tracked via initialMessages.length), NOT when old messages are prepended via load-more
useEffect(() => {
if (!msgsLoading && initialMessages.length > 0) {
requestAnimationFrame(() => {
setTimeout(() => {
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
if (viewport) viewport.scrollTop = viewport.scrollHeight;
}, 80);
});
}
}, [initialMessages.length, msgsLoading]);
// ── Mark as read when switching conversations ─────────────────────────────────
const markReadMutation = useMutation({
mutationFn: async (convId: number) => {
await apiRequest('POST', `/api/messenger/conversations/${convId}/read`);
},
onSuccess: (_, convId) => {
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
if (!old) return old;
return {
...old,
conversations: old.conversations.map((c: Conversation) =>
c.id === convId ? { ...c, unreadCount: 0 } : c
),
};
});
queryClient.invalidateQueries({ queryKey: ['/api/messenger/unread-count'] });
},
});
const handleSelectConv = (id: number) => {
setActiveConvId(id);
setReplyTo(null);
setEditingMsg(null);
setMessageText('');
setSearchQuery('');
markReadMutation.mutate(id);
if (isMobile) setMobileView('chat');
setLocation(`/chat/${id}`);
};
// ── Открытие/создание диалога из поиска ─────────────────────────────────────
const openDirectMutation = useMutation({
mutationFn: async (targetUserId: number) => {
const res = await apiRequest('POST', '/api/messenger/conversations', { type: 'direct', targetUserId });
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
},
onSuccess: (conv) => {
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
if (!old) return { success: true, conversations: [conv] };
const exists = old.conversations.some((c: Conversation) => c.id === conv.id);
if (exists) return old;
return { ...old, conversations: [conv, ...old.conversations] };
});
handleSelectConv(conv.id);
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const openExternalDirectMutation = useMutation({
mutationFn: async ({ connectionId, targetUserId }: { connectionId: number; targetUserId: number }) => {
const res = await apiRequest('POST', '/api/messenger/conversations', { type: 'external_direct', connectionId, targetUserId });
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
},
onSuccess: (conv) => {
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
if (!old) return { success: true, conversations: [conv] };
const exists = old.conversations.some((c: Conversation) => c.id === conv.id);
if (exists) return old;
return { ...old, conversations: [conv, ...old.conversations] };
});
handleSelectConv(conv.id);
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const muteMutation = useMutation({
mutationFn: async ({ convId, muted }: { convId: number; muted: boolean }) => {
const res = await apiRequest('PATCH', `/api/messenger/conversations/${convId}/mute`, { muted });
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
},
onSuccess: (updatedConv) => {
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
if (!old) return old;
return {
...old,
conversations: old.conversations.map((c: Conversation) =>
c.id === updatedConv.id ? { ...c, isMuted: updatedConv.isMuted } : c
),
};
});
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations', updatedConv.id] });
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const handleSearchSelectPerson = (personId: number) => {
setSearchQuery('');
const existing = conversations.find(c =>
c.type === 'direct' && !c.isExternal && c.members.some(m => m.id === personId)
);
if (existing) { handleSelectConv(existing.id); return; }
openDirectMutation.mutate(personId);
};
const handleSearchSelectExternal = (u: { id: number; connectionId: number }) => {
setSearchQuery('');
openExternalDirectMutation.mutate({ connectionId: u.connectionId, targetUserId: u.id });
};
const handleSearchSelectGroup = (convId: number) => {
setSearchQuery('');
handleSelectConv(convId);
};
const openBotDirectMutation = useMutation({
mutationFn: async (botId: number) => {
const res = await apiRequest('POST', '/api/messenger/conversations', { type: 'bot_direct', botId });
const data = await res.json();
if (!data.success) throw new Error(data.error ?? 'Ошибка');
return data.conversation as Conversation;
},
onSuccess: (conv) => {
queryClient.setQueryData<{ success: boolean; conversations: Conversation[] }>(
['/api/messenger/conversations'],
(old) => {
if (!old) return { success: true, conversations: [conv] };
const exists = old.conversations.some(c => c.id === conv.id);
return { ...old, conversations: exists ? old.conversations : [conv, ...old.conversations] };
}
);
handleSelectConv(conv.id);
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const handleSearchSelectBot = (botId: number) => {
setSearchQuery('');
const existing = conversations.find(c => c.type === 'bot_direct' && c.botId === botId);
if (existing) { handleSelectConv(existing.id); return; }
openBotDirectMutation.mutate(botId);
};
// ── Send message ──────────────────────────────────────────────────────────────
const handleFileSelect = (files: FileList | null) => {
if (!files) return;
setPendingFiles(prev => [...prev, ...Array.from(files)].slice(0, 10));
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFileSelect(e.dataTransfer.files);
}
};
const handleDragOver = (e: React.DragEvent) => {
// Подсвечиваем зону дропа только для файлов, чтобы не конфликтовать с выделением текста
if (!Array.from(e.dataTransfer.types).includes('Files')) return;
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const files = e.clipboardData.files;
if (files && files.length > 0) {
e.preventDefault();
handleFileSelect(files);
}
};
const sendMutation = useMutation({
mutationFn: async (payload: { message: string; replyToId?: number; mentionedUserIds?: number[]; attachments?: ChatAttachment[] }) => {
const res = await apiRequest('POST', `/api/messenger/conversations/${activeConvId}/messages`, payload);
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.message as ConvMessage;
},
onMutate: async (payload) => {
await queryClient.cancelQueries({ queryKey: ['/api/messenger/conversations', activeConvId, 'messages'] });
const previous = queryClient.getQueryData(['/api/messenger/conversations', activeConvId, 'messages']);
const tempId = -Math.floor(Math.random() * 1_000_000);
const optimistic: ConvMessage = {
id: tempId,
conversationId: activeConvId!,
message: payload.message,
replyToId: payload.replyToId ?? null,
mentionedUserIds: payload.mentionedUserIds ?? null,
attachments: payload.attachments ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isDeleted: false,
author: { id: user!.id, firstName: user!.firstName, lastName: user!.lastName, middleName: (user as any).middleName },
replyTo: replyTo ? { id: replyTo.id, message: replyTo.message, author: replyTo.author } : null,
};
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
(old: any) => {
if (!old) return { success: true, messages: [optimistic] };
return { ...old, messages: [...old.messages, optimistic] };
}
);
return { previous, tempId };
},
onSuccess: (msg, _, context) => {
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
(old: any) => {
if (!old) return { success: true, messages: [msg] };
const cleaned = (old.messages as ConvMessage[]).filter(
(m) => m.id !== context?.tempId && m.id !== msg.id
);
return { ...old, messages: [...cleaned, msg] };
}
);
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
if (!old) return old;
return {
...old,
conversations: old.conversations.map((c: Conversation) =>
c.id === activeConvId ? { ...c, lastMessage: msg, updatedAt: msg.createdAt } : c
),
};
});
setMessageText('');
setReplyTo(null);
},
onError: (err: any, _, context) => {
if (context?.previous) {
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
context.previous
);
}
toast({ variant: 'destructive', title: err.message });
},
});
const editMutation = useMutation({
mutationFn: async ({ msgId, message }: { msgId: number; message: string }) => {
const res = await apiRequest('PATCH', `/api/messenger/messages/${msgId}`, { message });
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.message as ConvMessage;
},
onSuccess: (msg) => {
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
(old: any) => {
if (!old) return old;
return { ...old, messages: old.messages.map((m: ConvMessage) => m.id === msg.id ? msg : m) };
}
);
setEditingMsg(null);
setEditText('');
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const deleteMutation = useMutation({
mutationFn: async (msgId: number) => {
await apiRequest('DELETE', `/api/messenger/messages/${msgId}`);
},
onSuccess: (_, msgId) => {
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
(old: any) => {
if (!old) return old;
return {
...old,
messages: old.messages.map((m: ConvMessage) => m.id === msgId ? { ...m, isDeleted: true } : m),
};
}
);
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
// ── @mentions ─────────────────────────────────────────────────────────────────
const members = activeConv?.members ?? [];
const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setMessageText(val);
// Detect @word at end of text
const match = val.match(/@(\w*)$/);
if (match) {
setMentionSearch(match[1]);
} else {
setMentionSearch(null);
}
};
const mentionCandidates = mentionSearch !== null
? members.filter(m =>
m.id !== user?.id &&
`${m.firstName} ${m.middleName || ''} ${m.lastName}`.toLowerCase().includes(mentionSearch.toLowerCase())
)
: [];
const handleSelectMention = (m: ConvUser) => {
const newText = messageText.replace(/@(\w*)$/, `@${m.firstName} ${m.middleName || ''} `);
setMessageText(newText);
setMentionSearch(null);
setMentionedUserIds(prev => prev.includes(m.id) ? prev : [...prev, m.id]);
inputRef.current?.focus();
};
function getOrgIdFromToken(token: string | null): string | null {
if (!token) return null;
try {
const [, payload] = token.split('.');
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
return decoded.organizationId ? String(decoded.organizationId) : null;
} catch {
return null;
}
}
const handleSend = async () => {
if (!messageText.trim() && pendingFiles.length === 0) return;
if (!activeConvId) return;
if (pendingFiles.length > 0) {
setIsUploading(true);
enqueueAttachment({
type: 'messenger',
targetId: activeConvId,
message: messageText.trim(),
replyToId: replyTo?.id,
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : undefined,
files: pendingFiles,
token: null,
orgId: null,
})
.then(async (attachmentId) => {
const tempId = -Math.floor(Math.random() * 1_000_000);
const optimistic: ConvMessage = {
id: tempId,
conversationId: activeConvId,
message: messageText.trim(),
replyToId: replyTo?.id ?? null,
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : null,
attachments: pendingFiles.map(f => ({
name: f.name,
size: f.size,
mimeType: f.type || undefined,
url: '',
})),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isDeleted: false,
author: { id: user!.id, firstName: user!.firstName, lastName: user!.lastName, middleName: (user as any).middleName },
replyTo: replyTo ? { id: replyTo.id, message: replyTo.message, author: replyTo.author } : null,
pending: true,
attachmentQueueItemId: attachmentId,
};
queryClient.setQueryData(
['/api/messenger/conversations', activeConvId, 'messages'],
(old: any) => {
if (!old) return { success: true, messages: [optimistic] };
return { ...old, messages: [...old.messages, optimistic] };
}
);
setPendingFiles([]);
setMessageText('');
setReplyTo(null);
setMentionedUserIds([]);
setMentionSearch(null);
await registerAttachmentBackgroundSync();
dispatchAttachmentQueueUpdated();
})
.catch((err: any) => {
toast({ variant: 'destructive', title: err.message || 'Не удалось поставить сообщение в очередь' });
})
.finally(() => setIsUploading(false));
return;
}
sendMutation.mutate({
message: messageText.trim(),
replyToId: replyTo?.id,
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : undefined,
attachments: undefined,
});
setMentionedUserIds([]);
setMentionSearch(null);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && mentionSearch !== null) {
e.preventDefault();
setMentionSearch(null);
return;
}
if (e.key === 'Enter' && !e.shiftKey && mentionSearch === null) {
// В PWA/standalone-режиме Enter вставляет новую строку, отправка — только кнопкой
if (isPWAInstalled()) return;
e.preventDefault();
handleSend();
}
};
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && editingMsg) {
// В PWA/standalone-режиме Enter вставляет новую строку, сохранение — только кнопкой
if (isPWAInstalled()) return;
e.preventDefault();
if (editText.trim()) editMutation.mutate({ msgId: editingMsg.id, message: editText.trim() });
}
if (e.key === 'Escape') {
setEditingMsg(null);
setEditText('');
}
};
const groupedMessages = groupByDate(messages);
// ── Render ────────────────────────────────────────────────────────────────────
return (
<Sidebar>
<div className="flex h-full">
{/* Left panel — conversation list */}
<div className={cn(
"shrink-0 border-r border-border flex flex-col h-full relative",
isMobile ? "w-full" : "w-64",
isMobile && mobileView !== 'list' && "hidden",
)}>
<div className="flex items-center gap-1.5 px-2 py-2 border-b border-border">
<div className="relative flex-1">
<Input
ref={searchInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Поиск..."
className="h-7 text-xs pr-6"
/>
{searchQuery && (
<button
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery('')}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={() => setNewConvOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* Результаты поиска — абсолютный оверлей поверх списка бесед */}
{searchResults && (
<div className="absolute top-[calc(2.25rem+1px)] left-0 right-0 z-50 border border-border rounded-b-md bg-background shadow-lg max-h-72 overflow-y-auto">
{searchResults.people.length === 0 && searchResults.groups.length === 0 && searchResults.external.length === 0 && (searchResults.bots ?? []).length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">Ничего не найдено</p>
)}
{searchResults.people.length > 0 && (
<>
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide px-3 pt-2 pb-1">Люди</p>
{searchResults.people.map(p => (
<button
key={p.id}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/60 transition-colors text-left"
onClick={() => handleSearchSelectPerson(p.id)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className={cn(getAvatarColor(p.id), "text-white text-[8px]")}>{getUserInitials(p.firstName, p.lastName, p.middleName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-xs font-medium truncate"><HighlightMatch text={`${p.firstName} ${p.middleName || ''} ${p.lastName}`} query={debouncedSearch} /></p>
{p.position && <p className="text-[10px] text-muted-foreground truncate">{p.position}</p>}
</div>
</button>
))}
</>
)}
{searchResults.groups.length > 0 && (
<>
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide px-3 pt-2 pb-1">Группы</p>
{searchResults.groups.map(g => (
<button
key={g.id}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/60 transition-colors text-left"
onClick={() => handleSearchSelectGroup(g.id)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className="text-[9px]"><Users className="w-3 h-3" /></AvatarFallback>
</Avatar>
<p className="text-xs font-medium truncate"><HighlightMatch text={g.name} query={debouncedSearch} /></p>
</button>
))}
</>
)}
{searchResults.external.length > 0 && (
<>
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide px-3 pt-2 pb-1">Внешние</p>
{searchResults.external.map(u => (
<button
key={u.id}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/60 transition-colors text-left"
onClick={() => handleSearchSelectExternal(u)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className="text-[9px]">{getUserInitials(u.firstName, u.lastName, u.middleName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-xs font-medium truncate"><HighlightMatch text={`${u.firstName} ${u.middleName || ''} ${u.lastName}`} query={debouncedSearch} /></p>
<div className="flex items-center gap-0.5">
<Link2 className="w-2.5 h-2.5 text-blue-500 shrink-0" />
{u.position && <p className="text-[10px] text-muted-foreground truncate">{u.position}</p>}
</div>
</div>
</button>
))}
</>
)}
{(searchResults.bots ?? []).length > 0 && (
<>
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide px-3 pt-2 pb-1">AI-боты</p>
{(searchResults.bots ?? []).map(bot => (
<button
key={bot.id}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/60 transition-colors text-left"
onClick={() => handleSearchSelectBot(bot.id)}
>
<Avatar className="w-6 h-6 shrink-0">
{bot.avatarUrl
? <img src={bot.avatarUrl} alt={bot.name} className="w-full h-full object-cover rounded-full" />
: <AvatarFallback className="text-[10px]">🤖</AvatarFallback>
}
</Avatar>
<div className="min-w-0">
<p className="text-xs font-medium truncate"><HighlightMatch text={bot.name} query={debouncedSearch} /></p>
<p className="text-[10px] text-muted-foreground truncate">@{bot.login}</p>
</div>
</button>
))}
</>
)}
</div>
)}
<ScrollArea className="flex-1">
{convsLoading && (
<div className="text-xs text-muted-foreground text-center py-6">Загрузка...</div>
)}
{!convsLoading && conversations.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-8 px-4">
Нет диалогов. Нажмите + чтобы начать общение.
</div>
)}
<div className="p-1 space-y-0.5">
{conversations.map(conv => {
const isActive = conv.id === activeConvId;
const lastMsgText = conv.lastMessage?.isDeleted
? 'Сообщение удалено'
: (conv.lastMessage?.message?.trim() || (conv.lastMessage?.attachments?.length ? '📎 Вложение' : ''));
return (
<div
key={conv.id}
onClick={() => handleSelectConv(conv.id)}
className={cn(
"flex items-center gap-2 px-2 py-2 rounded cursor-pointer transition-colors",
isActive ? "bg-muted" : "hover:bg-muted/50"
)}
>
<div className="relative shrink-0">
<Avatar className="w-8 h-8">
{conv.type === 'bot_direct' && conv.bot?.avatarUrl
? <img src={conv.bot.avatarUrl} alt={conv.bot.name} className="w-full h-full object-cover rounded-full" />
: (
<AvatarFallback className="text-xs">
{conv.type === 'group'
? <Users className="w-4 h-4" />
: conv.type === 'bot_direct'
? <span className="text-base">🤖</span>
: (() => {
const other = conv.members.find(m => m.id !== user?.id);
return other ? getUserInitials(other.firstName, other.lastName, other.middleName) : '?';
})()
}
</AvatarFallback>
)
}
</Avatar>
{conv.isExternal && (
<span className="absolute -bottom-0.5 -right-0.5 bg-blue-500 rounded-full p-0.5">
<Link2 className="w-2 h-2 text-white" />
</span>
)}
{conv.unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-[9px] font-bold rounded-full min-w-[14px] h-[14px] flex items-center justify-center px-0.5">
{conv.unreadCount > 99 ? '99+' : conv.unreadCount}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center">
<span className={cn("text-xs font-medium truncate", conv.unreadCount > 0 && "font-semibold")}>
{conv.displayName}
</span>
<div className="flex items-center gap-1 shrink-0 ml-1">
{conv.lastMessage?.createdAt && (
<span className="text-[10px] text-muted-foreground">
{format(new Date(conv.lastMessage.createdAt), 'HH:mm')}
</span>
)}
<button
title={conv.isMuted ? 'Включить уведомления' : 'Отключить уведомления'}
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
muteMutation.mutate({ convId: conv.id, muted: !conv.isMuted });
}}
>
{conv.isMuted
? <BellOff className="w-3 h-3" />
: <Bell className="w-3 h-3" />
}
</button>
</div>
</div>
{lastMsgText && (
<p className="text-[10px] text-muted-foreground truncate">{lastMsgText}</p>
)}
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
{/* Right panel — chat window */}
{(!isMobile || mobileView === 'chat') && (!activeConvId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<MessageSquare className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">Выберите диалог</p>
</div>
</div>
) : (
<div className="flex-1 flex flex-col min-w-0 relative">
{/* Chat header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
<div className="flex items-center gap-2 min-w-0">
{isMobile && (
<button
onClick={() => setMobileView('list')}
aria-label="Назад"
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground shrink-0 mr-1"
>
<ChevronLeft className="w-5 h-5" />
<span className="text-xs">Назад</span>
</button>
)}
<Avatar
className={cn("w-7 h-7", activeConv?.type === 'group' && "cursor-pointer hover:opacity-80 transition-opacity")}
onClick={() => activeConv?.type === 'group' && setGroupMembersOpen(true)}
>
{activeConv?.type === 'bot_direct' && activeConv.bot?.avatarUrl
? <img src={activeConv.bot.avatarUrl} alt={activeConv.bot.name} className="w-full h-full object-cover rounded-full" />
: (
<AvatarFallback className="text-xs">
{activeConv?.type === 'group'
? <Users className="w-3.5 h-3.5" />
: activeConv?.type === 'bot_direct'
? <span className="text-base">🤖</span>
: (() => {
const other = activeConv?.members.find(m => m.id !== user?.id);
return other ? getUserInitials(other.firstName, other.lastName, other.middleName) : '?';
})()
}
</AvatarFallback>
)
}
</Avatar>
<div
className={cn("min-w-0", activeConv?.type === 'group' && "cursor-pointer hover:opacity-80 transition-opacity")}
onClick={() => activeConv?.type === 'group' && setGroupMembersOpen(true)}
title={activeConv?.type === 'group' ? 'Участники группы' : undefined}
>
<p className="text-sm font-medium truncate">{activeConv?.displayName}</p>
{activeConv?.type === 'group' && (
<p className="text-[10px] text-muted-foreground">
{activeConv.members.length} участников · нажмите чтобы открыть
</p>
)}
</div>
</div>
{activeConv && (
<button
title={activeConv.isMuted ? 'Включить уведомления' : 'Отключить уведомления'}
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded"
onClick={() => muteMutation.mutate({ convId: activeConv.id, muted: !activeConv.isMuted })}
>
{activeConv.isMuted
? <BellOff className="w-4 h-4" />
: <Bell className="w-4 h-4" />
}
</button>
)}
</div>
{/* Messages */}
<ScrollArea ref={scrollRef} className="flex-1 px-4 py-3 chat-scroll-area">
{msgsLoading && (
<div className="text-xs text-muted-foreground text-center py-6">Загрузка...</div>
)}
{!msgsLoading && messages.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-8">Начните общение</div>
)}
{!msgsLoading && hasMore && (
<div className="flex justify-center mb-3">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={loadMoreMessages}
disabled={isLoadingMore}
>
{isLoadingMore
? <><Loader2 className="w-3 h-3 mr-1.5 animate-spin" />Загрузка...</>
: 'Загрузить ещё'
}
</Button>
</div>
)}
<div className="space-y-4">
{groupedMessages.map(group => (
<div key={group.dateKey}>
<div className="flex items-center gap-2 my-2">
<div className="flex-1 border-t border-border" />
<span className="text-[10px] text-muted-foreground shrink-0">
{formatDateHeader(group.date)}
</span>
<div className="flex-1 border-t border-border" />
</div>
<div className="space-y-2">
{group.messages.map(msg => (
<MessageBubble
key={msg.id}
msg={msg}
isOwn={!!msg.author && msg.author.id === user?.id}
onReply={m => { setReplyTo(m); inputRef.current?.focus(); }}
onEdit={m => { setEditingMsg(m); setEditText(m.message); }}
onDelete={m => deleteMutation.mutate(m.id)}
reactions={reactionsMap[msg.id]}
poll={pollsMap[msg.id]}
onReactionsChange={(r) => setReactionsMap(prev => ({ ...prev, [msg.id]: r }))}
onPollChange={(p) => setPollsMap(prev => ({ ...prev, [msg.id]: p }))}
onOpenUserChat={(userId) => {
const existing = conversations.find(c =>
c.type === 'direct' && !c.isExternal && c.members.some(m => m.id === userId)
);
if (existing) { handleSelectConv(existing.id); return; }
openDirectMutation.mutate(userId);
}}
onOpenBotChat={(botId) => {
const existing = conversations.find(c => c.type === 'bot_direct' && c.botId === botId);
if (existing) { handleSelectConv(existing.id); return; }
openBotDirectMutation.mutate(botId);
}}
/>
))}
</div>
</div>
))}
</div>
</ScrollArea>
{/* Input area */}
<div className="shrink-0 border-t border-border p-3 space-y-2">
{replyTo && (
<div className="flex items-center gap-2 px-2 py-1 rounded bg-muted text-xs text-muted-foreground">
<Reply className="w-3 h-3 shrink-0" />
<span className="flex-1 truncate">
<span className="font-medium">{replyTo.author?.firstName} {replyTo.author?.middleName || ''} {replyTo.author?.lastName}:</span> {replyTo.message}
</span>
<button onClick={() => setReplyTo(null)} className="hover:text-foreground">
<X className="w-3 h-3" />
</button>
</div>
)}
{/* @mention dropdown */}
{mentionSearch !== null && mentionCandidates.length > 0 && (
<div className="border border-border rounded-md bg-background shadow-md overflow-hidden">
{mentionCandidates.map(m => (
<div
key={m.id}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-muted cursor-pointer"
onMouseDown={(e) => { e.preventDefault(); handleSelectMention(m); }}
>
<Avatar className="w-5 h-5 shrink-0">
<AvatarFallback className={cn(getAvatarColor(m.id), "text-white text-[7px]")}>{getUserInitials(m.firstName, m.lastName, m.middleName)}</AvatarFallback>
</Avatar>
<span className="text-xs">{m.firstName} {m.middleName || ''} {m.lastName}</span>
</div>
))}
</div>
)}
{editingMsg ? (
<div className="flex gap-2 items-end">
<AutoResizeTextarea
ref={inputRef}
value={editText}
onChange={e => setEditText(e.target.value)}
onKeyDown={handleEditKeyDown}
placeholder="Редактировать..."
className="flex-1 text-sm resize-none min-h-[32px]"
minRows={1}
maxRows={5}
autoFocus
/>
<Button
size="sm"
className="h-8"
onClick={() => editingMsg && editMutation.mutate({ msgId: editingMsg.id, message: editText.trim() })}
disabled={!editText.trim() || editMutation.isPending}
>
<Check className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setEditingMsg(null); setEditText(''); }}>
<X className="w-3.5 h-3.5" />
</Button>
</div>
) : (
<>
<PendingAttachmentList
files={pendingFiles}
onRemove={(i) => setPendingFiles(prev => prev.filter((_, idx) => idx !== i))}
/>
<div
className={cn("flex gap-2 relative rounded-lg transition-colors", isDragOver && "ring-2 ring-primary/40 bg-primary/5")}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
title="Перетащите файл в поле сообщения"
>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
className="hidden"
onChange={e => handleFileSelect(e.target.files)}
/>
<AutoResizeTextarea
ref={inputRef}
value={messageText}
onChange={handleMessageChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Написать сообщение..."
className="flex-1 text-sm resize-none min-h-[32px]"
minRows={1}
maxRows={5}
/>
<Button
size="sm"
variant="ghost"
className="h-8 px-2 shrink-0"
onClick={() => fileInputRef.current?.click()}
title="Прикрепить файл"
>
<Paperclip className="w-3.5 h-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 px-2 shrink-0"
onClick={() => setPollDialogOpen(true)}
title="Создать опрос"
>
<BarChart2 className="w-3.5 h-3.5" />
</Button>
<Button
size="sm"
className="h-8"
onClick={handleSend}
disabled={(!messageText.trim() && pendingFiles.length === 0) || sendMutation.isPending || isUploading}
>
{(sendMutation.isPending || isUploading)
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Send className="w-3.5 h-3.5" />}
</Button>
</div>
</>
)}
</div>
</div>
))}
</div>
<PollCreateDialog
open={pollDialogOpen}
onOpenChange={setPollDialogOpen}
convId={activeConvId ?? undefined}
onCreated={(poll, messageId) => {
setPollsMap(prev => ({ ...prev, [messageId]: poll }));
setPollDialogOpen(false);
}}
/>
<NewConversationDialog
open={newConvOpen}
onOpenChange={setNewConvOpen}
onCreated={(conv) => {
handleSelectConv(conv.id);
}}
/>
{activeConv && activeConv.type === 'group' && user && (
<GroupMembersDialog
open={groupMembersOpen}
onOpenChange={setGroupMembersOpen}
conversation={activeConv}
currentUserId={user.id}
currentUserRole={user.appRole}
/>
)}
</Sidebar>
);
}