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 (
Сообщение удалено
); } return (
{!isOwn && ( {msg.bot?.avatarUrl ? {msg.bot.name} : ( {msg.bot ? 🤖 : msg.author ? getUserInitials(msg.author.firstName, msg.author.lastName, msg.author.middleName) : '?' } ) } )}
{!isOwn && ( {msg.bot ? ( ) : ( msg.author ? ( ) : '' )} )} {msg.replyTo && ( )}
{isOwn && (
)}
{msg.message}
{!isOwn && (
)}
{msg.attachments && msg.attachments.length > 0 && ( )} {/* Опрос */} {poll && onPollChange && ( )} {/* Реакции — пикер открывается по клику на пузырёк */} {onReactionsChange && ( )} {msg.pending && ( <> отправка )} {format(new Date(msg.createdAt), 'HH:mm')} {isEdited && ' · изменено'}
); } // ── 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([]); // External state const [selectedConnId, setSelectedConnId] = useState(null); const [extSearch, setExtSearch] = useState(''); const [extSelectedIds, setExtSelectedIds] = useState([]); 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 ( { onOpenChange(v); if (!v) reset(); }}> Новый чат {/* Tab: внутренний / внешний */}
{/* Тип чата */}
{mode === 'group' ? 'Групповой чат' : 'Личный чат'}
{/* ── Internal ── */} {tab === 'internal' && ( <> {mode === 'group' && ( setGroupName(e.target.value)} className="mb-2 h-8 text-sm" /> )} setSearch(e.target.value)} className="mb-2 h-8 text-sm" />
{filtered.map(u => (
toggleUser(u.id)} > {getUserInitials(u.firstName, u.lastName, u.middleName)}
{u.firstName} {u.middleName || ''} {u.lastName}
{u.position &&
{u.position}
}
{selectedIds.includes(u.id) && }
))} {filtered.length === 0 && (
Пользователи не найдены
)}
)} {/* ── External ── */} {tab === 'external' && ( <> {activeConns.length === 0 ? (

Нет активных подключений. Настройте кросс-организационное подключение в разделе Настройки → Подключения.

) : ( <> {/* Выбор подключения */} {!selectedConnId ? (

Выберите партнёрскую организацию:

{activeConns.map(conn => (
setSelectedConnId(conn.id)} > {getConnName(conn)}
))}
) : ( <> {/* Заголовок партнёра + кнопка назад */}
{selectedConn ? getConnName(selectedConn) : ''}
{mode === 'group' && ( setExtGroupName(e.target.value)} className="mb-2 h-8 text-sm" /> )} setExtSearch(e.target.value)} className="mb-2 h-8 text-sm" />
{extUsersLoading && (
Загрузка...
)} {!extUsersLoading && extUsers.map(u => (
toggleExtUser(u.id)} > {getUserInitials(u.firstName, u.lastName, u.middleName)}
{u.firstName} {u.middleName || ''} {u.lastName}
{u.position &&
{u.position}
}
{extSelectedIds.includes(u.id) && }
))} {!extUsersLoading && extUsers.length === 0 && (
Нет пользователей
)}
)} )} )}
); } // ── 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 ( { onOpenChange(v); if (!v) { setShowAdd(false); setAddSearch(''); } }}> {conversation.displayName} · участники {/* Список участников */}
{conversation.members.map(m => { const isMe = m.id === currentUserId; const isCreatorTag = conversation.createdBy === m.id && !conversation.isDefault; const canRemove = isGroupAdmin && m.id !== currentUserId; return (
{getUserInitials(m.firstName, m.lastName, m.middleName)}
{m.firstName} {m.middleName || ''} {m.lastName} {isMe && (я)} {isCreatorTag && · создатель}
{m.position &&
{m.position}
}
{canRemove && ( )}
); })}
{/* Добавить участника (только для группового администратора) */} {isGroupAdmin && ( <> {!showAdd ? ( ) : (
setAddSearch(e.target.value)} className="h-8 text-xs" />
{availableToAdd.map(u => (
addMutation.mutate(u.id)} > {getUserInitials(u.firstName, u.lastName, u.middleName)} {u.firstName} {u.middleName || ''} {u.lastName}
))} {availableToAdd.length === 0 && (
Нет доступных пользователей
)}
)} )} {/* Удалить группу (только для создателя или админа) */} {canDeleteGroup && ( )} {/* Покинуть группу (только для не-создателей, не «Наша Фирма») */} {canLeave && ( )}
); } // ── 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)} {text.slice(idx, idx + query.length)} {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(null); const [newConvOpen, setNewConvOpen] = useState(false); const [groupMembersOpen, setGroupMembersOpen] = useState(false); const [replyTo, setReplyTo] = useState(null); const [editingMsg, setEditingMsg] = useState(null); const [messageText, setMessageText] = useState(''); const [editText, setEditText] = useState(''); const [mentionedUserIds, setMentionedUserIds] = useState([]); const [mentionSearch, setMentionSearch] = useState(null); const inputRef = useRef(null); const scrollRef = useRef(null); // Reactions & polls const [reactionsMap, setReactionsMap] = useState>({}); const [pollsMap, setPollsMap] = useState>({}); const [pollDialogOpen, setPollDialogOpen] = useState(false); // Search const [searchQuery, setSearchQuery] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const searchInputRef = useRef(null); // File attachments const [pendingFiles, setPendingFiles] = useState([]); const fileInputRef = useRef(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>(new Set()); const activeAttachmentIdsRef = useRef>(new Set()); // Pagination for loading older messages const [olderMessages, setOlderMessages] = useState([]); 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) => { 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) => { 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) => { 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) => { 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 (
{/* Left panel — conversation list */}
setSearchQuery(e.target.value)} placeholder="Поиск..." className="h-7 text-xs pr-6" /> {searchQuery && ( )}
{/* Результаты поиска — абсолютный оверлей поверх списка бесед */} {searchResults && (
{searchResults.people.length === 0 && searchResults.groups.length === 0 && searchResults.external.length === 0 && (searchResults.bots ?? []).length === 0 && (

Ничего не найдено

)} {searchResults.people.length > 0 && ( <>

Люди

{searchResults.people.map(p => ( ))} )} {searchResults.groups.length > 0 && ( <>

Группы

{searchResults.groups.map(g => ( ))} )} {searchResults.external.length > 0 && ( <>

Внешние

{searchResults.external.map(u => ( ))} )} {(searchResults.bots ?? []).length > 0 && ( <>

AI-боты

{(searchResults.bots ?? []).map(bot => ( ))} )}
)} {convsLoading && (
Загрузка...
)} {!convsLoading && conversations.length === 0 && (
Нет диалогов. Нажмите + чтобы начать общение.
)}
{conversations.map(conv => { const isActive = conv.id === activeConvId; const lastMsgText = conv.lastMessage?.isDeleted ? 'Сообщение удалено' : (conv.lastMessage?.message?.trim() || (conv.lastMessage?.attachments?.length ? '📎 Вложение' : '')); return (
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" )} >
{conv.type === 'bot_direct' && conv.bot?.avatarUrl ? {conv.bot.name} : ( {conv.type === 'group' ? : conv.type === 'bot_direct' ? 🤖 : (() => { const other = conv.members.find(m => m.id !== user?.id); return other ? getUserInitials(other.firstName, other.lastName, other.middleName) : '?'; })() } ) } {conv.isExternal && ( )} {conv.unreadCount > 0 && ( {conv.unreadCount > 99 ? '99+' : conv.unreadCount} )}
0 && "font-semibold")}> {conv.displayName}
{conv.lastMessage?.createdAt && ( {format(new Date(conv.lastMessage.createdAt), 'HH:mm')} )}
{lastMsgText && (

{lastMsgText}

)}
); })}
{/* Right panel — chat window */} {(!isMobile || mobileView === 'chat') && (!activeConvId ? (

Выберите диалог

) : (
{/* Chat header */}
{isMobile && ( )} activeConv?.type === 'group' && setGroupMembersOpen(true)} > {activeConv?.type === 'bot_direct' && activeConv.bot?.avatarUrl ? {activeConv.bot.name} : ( {activeConv?.type === 'group' ? : activeConv?.type === 'bot_direct' ? 🤖 : (() => { const other = activeConv?.members.find(m => m.id !== user?.id); return other ? getUserInitials(other.firstName, other.lastName, other.middleName) : '?'; })() } ) }
activeConv?.type === 'group' && setGroupMembersOpen(true)} title={activeConv?.type === 'group' ? 'Участники группы' : undefined} >

{activeConv?.displayName}

{activeConv?.type === 'group' && (

{activeConv.members.length} участников · нажмите чтобы открыть

)}
{activeConv && ( )}
{/* Messages */} {msgsLoading && (
Загрузка...
)} {!msgsLoading && messages.length === 0 && (
Начните общение
)} {!msgsLoading && hasMore && (
)}
{groupedMessages.map(group => (
{formatDateHeader(group.date)}
{group.messages.map(msg => ( { 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); }} /> ))}
))}
{/* Input area */}
{replyTo && (
{replyTo.author?.firstName} {replyTo.author?.middleName || ''} {replyTo.author?.lastName}: {replyTo.message}
)} {/* @mention dropdown */} {mentionSearch !== null && mentionCandidates.length > 0 && (
{mentionCandidates.map(m => (
{ e.preventDefault(); handleSelectMention(m); }} > {getUserInitials(m.firstName, m.lastName, m.middleName)} {m.firstName} {m.middleName || ''} {m.lastName}
))}
)} {editingMsg ? (
setEditText(e.target.value)} onKeyDown={handleEditKeyDown} placeholder="Редактировать..." className="flex-1 text-sm resize-none min-h-[32px]" minRows={1} maxRows={5} autoFocus />
) : ( <> setPendingFiles(prev => prev.filter((_, idx) => idx !== i))} />
handleFileSelect(e.target.files)} />
)}
))}
{ setPollsMap(prev => ({ ...prev, [messageId]: poll })); setPollDialogOpen(false); }} /> { handleSelectConv(conv.id); }} /> {activeConv && activeConv.type === 'group' && user && ( )} ); }