diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index a22411a..594c02f 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -348,3 +348,13 @@ - Как проверялось: docker compose config валиден для всех 4 файлов; check/lint/build чисто; vitest 104/104. - Влияние на поиск/UX: нет. - Подводные камни: пины синхронизированы в серверный бэкап .bak.serverfix (крит. правило №1) — только image-теги, креды бэкапа не тронуты; локальный .env.local требует MINIO_ACCESS_KEY (есть в .env.local.example). + +--- + +## [2.2a] Разбиение Chat.tsx (2696 → 639 строк) + +- Статус: ✅ done +- Зачем: гигантские файлы нечитаемы и рискованны в правках (Фаза 2). БЕЗ смены поведения. +- Что изменено: `client/src/pages/Chat.tsx` → 8 файлов в `client/src/components/chat/`: chat-types.ts, chat-utils.ts, MessageBubble.tsx (пузырь + кнопки ботов), NewConversationDialog.tsx, GroupMembersDialog.tsx, HighlightMatch.tsx, TypingIndicator.tsx, useChatController.ts (вся логика страницы дословно). В Chat.tsx — только деструктуризация хука + прежний JSX. +- Как проверялось: ПРОГРАММНАЯ сверка с оригиналом (git show): тело логики == useChatController посимвольно, рендер == оригиналу (кроме замены inline-блока на ). Чеклист real-time фич дословно на месте (SSE typing, reportActiveChat в useLayoutEffect, polling fallback 10с, оптимистичная автоотметка прочтения с debounce, кнопки ботов с «нажато»). check/lint/build чисто; vitest 104/104. +- Влияние на поиск/UX: нет (поведение идентично доказанно). diff --git a/client/src/components/chat/GroupMembersDialog.tsx b/client/src/components/chat/GroupMembersDialog.tsx new file mode 100644 index 0000000..c42caa8 --- /dev/null +++ b/client/src/components/chat/GroupMembersDialog.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { apiRequest, queryClient } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { getAvatarColor, getUserInitials } from '@/lib/avatar'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Trash2, UserPlus, UserMinus, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Conversation, ConvUser } from './chat-types'; + +// ── Group Members Dialog (unified: view + add) ───────────────────────────────── + +export 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); + const [deleteConfirmOpen, setDeleteConfirmOpen] = 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 && ( + + )} +
+ + {/* Подтверждение удаления группы */} + + + + Удалить группу? + + Группа «{conversation.displayName}» будет удалена навсегда. Это действие нельзя отменить. + + + + Отмена + deleteGroupMutation.mutate()} + > + Удалить группу + + + + +
+ ); +} diff --git a/client/src/components/chat/HighlightMatch.tsx b/client/src/components/chat/HighlightMatch.tsx new file mode 100644 index 0000000..0f09e96 --- /dev/null +++ b/client/src/components/chat/HighlightMatch.tsx @@ -0,0 +1,14 @@ +// ── Highlight matching substring in search results ──────────────────────────── + +export 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)} + + ); +} diff --git a/client/src/components/chat/MessageBubble.tsx b/client/src/components/chat/MessageBubble.tsx new file mode 100644 index 0000000..87e5077 --- /dev/null +++ b/client/src/components/chat/MessageBubble.tsx @@ -0,0 +1,284 @@ +import { useState } from 'react'; +import { format } from 'date-fns'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { getAvatarColor, getUserInitials } from '@/lib/avatar'; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Reply, Edit, Check, MoreVertical, Trash2, Clock, Smile, +} from 'lucide-react'; +import { AttachmentDisplay } 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 type { BotButton, ConvMessage } from './chat-types'; +import { groupButtonsByRow } from './chat-utils'; + +// ── Message bubble ───────────────────────────────────────────────────────────── + +export function MessageBubble({ + msg, isOwn, onReply, onEdit, onDelete, reactions, poll, onReactionsChange, onPollChange, + onOpenUserChat, onOpenBotChat, onButtonClick, pressedButtons, +}: { + 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; + onButtonClick?: (m: ConvMessage, button: BotButton) => void; + pressedButtons?: Set; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const isEdited = msg.updatedAt && msg.updatedAt !== msg.createdAt; + + // Мобильное меню действий ( + + + + + onReply(msg)}> + + Ответить + + {onReactionsChange && ( + setPickerOpen(true)}> + + Реакция + + )} + {isOwn && ( + onEdit(msg)}> + + Редактировать + + )} + {isOwn && ( + onDelete(msg)} + > + + Удалить + + )} + + + ); + + 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 && ( + <> +
+ + + + +
+ {mobileActionsMenu} + + )} + +
+ {msg.message} +
+ + {!isOwn && ( + <> + {mobileActionsMenu} +
+ + +
+ + )} +
+ + {msg.attachments && msg.attachments.length > 0 && ( + + )} + + {/* Опрос */} + {poll && onPollChange && ( + + )} + + {/* Реакции — пикер открывается по клику на пузырёк */} + {onReactionsChange && ( + + )} + + {/* Кнопки бота */} + {msg.botButtons && msg.botButtons.length > 0 && onButtonClick && !msg.isDeleted && ( +
+ {groupButtonsByRow(msg.botButtons).map((row, rowIdx) => ( +
+ {row.map((btn, btnIdx) => { + const btnKey = `${msg.id}:${btn.callbackData}`; + const isPressed = pressedButtons?.has(btnKey) ?? false; + return ( + + ); + })} +
+ ))} +
+ )} + + + {msg.pending && ( + <> + + отправка + + )} + {format(new Date(msg.createdAt), 'HH:mm')} + {isEdited && ' · изменено'} + +
+
+ ); +} diff --git a/client/src/components/chat/NewConversationDialog.tsx b/client/src/components/chat/NewConversationDialog.tsx new file mode 100644 index 0000000..a118750 --- /dev/null +++ b/client/src/components/chat/NewConversationDialog.tsx @@ -0,0 +1,414 @@ +import { useState, useMemo, useCallback } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { Link } from 'wouter'; +import { apiRequest, queryClient } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +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 { + Users, Check, Link2, ExternalLink, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Conversation, ConvUser, ExternalUser, OrgConnectionItem } from './chat-types'; + +// ── New Conversation Dialog ──────────────────────────────────────────────────── + +export 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 && ( +
Нет пользователей
+ )} +
+
+ + )} + + )} + + )} + + + + +
+
+ ); +} diff --git a/client/src/components/chat/TypingIndicator.tsx b/client/src/components/chat/TypingIndicator.tsx new file mode 100644 index 0000000..fb47ea0 --- /dev/null +++ b/client/src/components/chat/TypingIndicator.tsx @@ -0,0 +1,25 @@ +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; + +// ── Bot typing indicator ─────────────────────────────────────────────────────── + +export function TypingIndicator({ typingBots }: { typingBots: Set }) { + if (typingBots.size === 0) return null; + return ( +
+ + 🤖 + +
+ + {Array.from(typingBots).join(', ')} + + печатает + + + + + +
+
+ ); +} diff --git a/client/src/components/chat/chat-types.ts b/client/src/components/chat/chat-types.ts new file mode 100644 index 0000000..d6247f2 --- /dev/null +++ b/client/src/components/chat/chat-types.ts @@ -0,0 +1,83 @@ +import type { ChatAttachment } from '@/components/chat/ChatAttachments'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ConvUser { + id: number; + firstName: string; + lastName: string; + middleName?: string; + email?: string | null; + position?: string; + lastReadAt?: string | null; +} + +export interface ConvBot { + id: number; + name: string; + avatarUrl?: string | null; +} + +export interface BotButton { + text: string; + callbackData: string; + style?: 'primary' | 'secondary' | 'destructive' | 'outline'; + row?: number; +} + +export interface ConvMessage { + id: number; + conversationId: number; + message: string; + replyToId?: number | null; + mentionedUserIds?: number[] | null; + attachments?: ChatAttachment[] | null; + botButtons?: BotButton[] | 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; +} + +export 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; +} + +// ── Types for connections ────────────────────────────────────────────────────── + +export interface OrgConnectionItem { + id: number; + status: string; + isInitiator: boolean; + initiatorOrg: { id: number; displayName: string } | null; + partnerOrg: { id: number; displayName: string } | null; +} + +export interface ExternalUser { + id: number; + firstName: string; + lastName: string; + middleName?: string; + position?: string | null; + organizationId: number; +} diff --git a/client/src/components/chat/chat-utils.ts b/client/src/components/chat/chat-utils.ts new file mode 100644 index 0000000..02c1d77 --- /dev/null +++ b/client/src/components/chat/chat-utils.ts @@ -0,0 +1,37 @@ +import { format, isToday, isYesterday } from 'date-fns'; +import type { BotButton, ConvMessage } from './chat-types'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +export function formatDateHeader(date: Date) { + if (isToday(date)) return 'Сегодня'; + if (isYesterday(date)) return 'Вчера'; + return format(date, 'dd.MM.yyyy'); +} + +export 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; +} + +export function groupButtonsByRow(buttons: BotButton[]): BotButton[][] { + const rows = new Map(); + for (const btn of buttons) { + const rowIdx = btn.row ?? 0; + if (!rows.has(rowIdx)) rows.set(rowIdx, []); + rows.get(rowIdx)!.push(btn); + } + return Array.from(rows.entries()).sort((a, b) => a[0] - b[0]).map(([, btns]) => btns); +} diff --git a/client/src/components/chat/useChatController.ts b/client/src/components/chat/useChatController.ts new file mode 100644 index 0000000..d9e4483 --- /dev/null +++ b/client/src/components/chat/useChatController.ts @@ -0,0 +1,1175 @@ +import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useLocation, useParams } from 'wouter'; +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 { isPWAInstalled } from '@/lib/pwa'; +import type { ChatAttachment } from '@/components/chat/ChatAttachments'; +import type { ReactionGroup } from '@/components/chat/ReactionBar'; +import type { PollData } from '@/components/chat/PollCard'; +import type { Conversation, ConvMessage, ConvUser } from './chat-types'; +import { groupByDate } from './chat-utils'; + +// ── Chat page controller (вся логика страницы мессенджера) ───────────────────── + +export function useChatController() { + 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); + // Typing indicator for bots + const [typingBots, setTypingBots] = useState>(new Set()); + const typingTimersRef = useRef>(new Map()); + // Pressed bot buttons (messageId:callbackData) + const [pressedButtons, setPressedButtons] = useState>(new Set()); + // 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()); + const markReadTimerRef = useRef | null>(null); + + const debouncedMarkRead = useCallback((convId: number) => { + if (markReadTimerRef.current) clearTimeout(markReadTimerRef.current); + markReadTimerRef.current = setTimeout(() => { + markReadMutation.mutate(convId); + }, 300); + }, []); + + // 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. + // useLayoutEffect reports synchronously after DOM mutations so push suppression + // is active before any incoming message event is processed by the server. + useLayoutEffect(() => { + if (!activeConvId) return; + const visible = typeof document !== 'undefined' && document.visibilityState === 'visible'; + reportActiveChat('messenger', activeConvId, visible); + setActiveChatInSw('messenger', activeConvId, visible); + }, [activeConvId]); + + useEffect(() => { + if (!activeConvId) return; + + const report = (visible: boolean) => { + reportActiveChat('messenger', activeConvId, visible); + setActiveChatInSw('messenger', activeConvId, 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; + }, + }); + + // ── Typing indicator for bots ───────────────────────────────────────────────── + + const clearTypingBot = (botName: string) => { + const timer = typingTimersRef.current.get(botName); + if (timer) clearTimeout(timer); + typingTimersRef.current.delete(botName); + setTypingBots(prev => { + if (!prev.has(botName)) return prev; + const next = new Set(prev); + next.delete(botName); + return next; + }); + }; + + const addTypingBot = (botName: string) => { + const existing = typingTimersRef.current.get(botName); + if (existing) clearTimeout(existing); + setTypingBots(prev => { + if (prev.has(botName)) return prev; + const next = new Set(prev); + next.add(botName); + return next; + }); + typingTimersRef.current.set( + botName, + setTimeout(() => clearTypingBot(botName), 10000), + ); + }; + + useEffect(() => { + // Clear all typing indicators and pressed buttons when switching conversations + typingTimersRef.current.forEach(timer => clearTimeout(timer)); + typingTimersRef.current.clear(); + setTypingBots(new Set()); + setPressedButtons(new Set()); + }, [activeConvId]); + + // ── 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. Автоотметка прочтения только для активного, видимого чата и чужих сообщений. + // Оптимистично сбрасываем глобальный unread count, затем debounce'им запрос к серверу. + if (isActiveConv && !isMine && isVisible) { + queryClient.setQueryData<{ success: boolean; unreadCount: number }>(['/api/messenger/unread-count'], (old) => { + if (!old) return old; + return { ...old, unreadCount: 0 }; + }); + debouncedMarkRead(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] }; + } + ); + }, []), + + onConvTypingStarted: useCallback((data: { conversationId: number; botName: string }) => { + if (data.conversationId !== activeConvId) return; + addTypingBot(data.botName); + }, [activeConvId, addTypingBot]), + + onConvTypingStopped: useCallback((data: { conversationId: number; botName: string }) => { + if (data.conversationId !== activeConvId) return; + clearTypingBot(data.botName); + }, [activeConvId, clearTypingBot]), + + 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 botButtonCallbackMutation = useMutation({ + mutationFn: async ({ messageId, callbackData }: { messageId: number; callbackData: string }) => { + await apiRequest('POST', `/api/messenger/messages/${messageId}/callback`, { callbackData }); + }, + }); + + const handleSelectConv = (id: number) => { + setActiveConvId(id); + setReplyTo(null); + setEditingMsg(null); + setMessageText(''); + setSearchQuery(''); + markReadMutation.mutate(id); + if (isMobile) setMobileView('chat'); + setLocation(`/chat/${id}`); + // Report active chat immediately so the server can suppress pushes for this chat + // before any new message event reaches the notification service. + const visible = typeof document !== 'undefined' && document.visibilityState === 'visible'; + reportActiveChat('messenger', id, visible); + setActiveChatInSw('messenger', id, visible); + }; + + // ── Открытие/создание диалога из поиска ───────────────────────────────────── + + 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); + + return { + user, + isMobile, + mobileView, + setMobileView, + searchInputRef, + searchQuery, + setSearchQuery, + newConvOpen, + setNewConvOpen, + searchResults, + debouncedSearch, + handleSearchSelectPerson, + handleSearchSelectGroup, + handleSearchSelectExternal, + handleSearchSelectBot, + convsLoading, + conversations, + activeConvId, + handleSelectConv, + muteMutation, + activeConv, + groupMembersOpen, + setGroupMembersOpen, + scrollRef, + msgsLoading, + messages, + hasMore, + loadMoreMessages, + isLoadingMore, + groupedMessages, + reactionsMap, + setReactionsMap, + pollsMap, + setPollsMap, + openDirectMutation, + openBotDirectMutation, + setPressedButtons, + botButtonCallbackMutation, + pressedButtons, + typingBots, + replyTo, + setReplyTo, + inputRef, + setEditingMsg, + setEditText, + deleteMutation, + editingMsg, + editText, + handleEditKeyDown, + editMutation, + mentionSearch, + mentionCandidates, + handleSelectMention, + pendingFiles, + setPendingFiles, + isDragOver, + handleFileSelect, + handleDrop, + handleDragOver, + handleDragLeave, + fileInputRef, + messageText, + handleMessageChange, + handleKeyDown, + handlePaste, + pollDialogOpen, + setPollDialogOpen, + handleSend, + sendMutation, + isUploading, + }; +} diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 65937a9..5712fca 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -1,19 +1,4 @@ -import { useState, useRef, useEffect, useCallback, useMemo, useLayoutEffect } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; -import { useLocation, useParams, Link } 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 { format } from 'date-fns'; import { Sidebar } from '@/components/Sidebar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -21,2122 +6,97 @@ 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 { - AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, - AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, -} from '@/components/ui/alert-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, + MessageSquare, Plus, Send, Reply, X, Check, + Users, Link2, Paperclip, Loader2, + ChevronLeft, Bell, BellOff, BarChart2, } from 'lucide-react'; -import { isPWAInstalled } from '@/lib/pwa'; -import { PendingAttachmentList, AttachmentDisplay } from '@/components/chat/ChatAttachments'; -import type { ChatAttachment } from '@/components/chat/ChatAttachments'; +import { PendingAttachmentList } 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 BotButton { - text: string; - callbackData: string; - style?: 'primary' | 'secondary' | 'destructive' | 'outline'; - row?: number; -} - -interface ConvMessage { - id: number; - conversationId: number; - message: string; - replyToId?: number | null; - mentionedUserIds?: number[] | null; - attachments?: ChatAttachment[] | null; - botButtons?: BotButton[] | 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; -} - -function groupButtonsByRow(buttons: BotButton[]): BotButton[][] { - const rows = new Map(); - for (const btn of buttons) { - const rowIdx = btn.row ?? 0; - if (!rows.has(rowIdx)) rows.set(rowIdx, []); - rows.get(rowIdx)!.push(btn); - } - return Array.from(rows.entries()).sort((a, b) => a[0] - b[0]).map(([, btns]) => btns); -} - -// ── Message bubble ───────────────────────────────────────────────────────────── - -function MessageBubble({ - msg, isOwn, onReply, onEdit, onDelete, reactions, poll, onReactionsChange, onPollChange, - onOpenUserChat, onOpenBotChat, onButtonClick, pressedButtons, -}: { - 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; - onButtonClick?: (m: ConvMessage, button: BotButton) => void; - pressedButtons?: Set; -}) { - const [pickerOpen, setPickerOpen] = useState(false); - const isEdited = msg.updatedAt && msg.updatedAt !== msg.createdAt; - - // Мобильное меню действий ( - - - - - onReply(msg)}> - - Ответить - - {onReactionsChange && ( - setPickerOpen(true)}> - - Реакция - - )} - {isOwn && ( - onEdit(msg)}> - - Редактировать - - )} - {isOwn && ( - onDelete(msg)} - > - - Удалить - - )} - - - ); - - 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 && ( - <> -
- - - - -
- {mobileActionsMenu} - - )} - -
- {msg.message} -
- - {!isOwn && ( - <> - {mobileActionsMenu} -
- - -
- - )} -
- - {msg.attachments && msg.attachments.length > 0 && ( - - )} - - {/* Опрос */} - {poll && onPollChange && ( - - )} - - {/* Реакции — пикер открывается по клику на пузырёк */} - {onReactionsChange && ( - - )} - - {/* Кнопки бота */} - {msg.botButtons && msg.botButtons.length > 0 && onButtonClick && !msg.isDeleted && ( -
- {groupButtonsByRow(msg.botButtons).map((row, rowIdx) => ( -
- {row.map((btn, btnIdx) => { - const btnKey = `${msg.id}:${btn.callbackData}`; - const isPressed = pressedButtons?.has(btnKey) ?? false; - return ( - - ); - })} -
- ))} -
- )} - - - {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); - const [deleteConfirmOpen, setDeleteConfirmOpen] = 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 && ( - - )} -
- - {/* Подтверждение удаления группы */} - - - - Удалить группу? - - Группа «{conversation.displayName}» будет удалена навсегда. Это действие нельзя отменить. - - - - Отмена - deleteGroupMutation.mutate()} - > - Удалить группу - - - - -
- ); -} - -// ── 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)} - - ); -} +import { MessageBubble } from '@/components/chat/MessageBubble'; +import { NewConversationDialog } from '@/components/chat/NewConversationDialog'; +import { GroupMembersDialog } from '@/components/chat/GroupMembersDialog'; +import { HighlightMatch } from '@/components/chat/HighlightMatch'; +import { TypingIndicator } from '@/components/chat/TypingIndicator'; +import { formatDateHeader } from '@/components/chat/chat-utils'; +import { useChatController } from '@/components/chat/useChatController'; // ── 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); - // Typing indicator for bots - const [typingBots, setTypingBots] = useState>(new Set()); - const typingTimersRef = useRef>(new Map()); - // Pressed bot buttons (messageId:callbackData) - const [pressedButtons, setPressedButtons] = useState>(new Set()); - // 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()); - const markReadTimerRef = useRef | null>(null); - - const debouncedMarkRead = useCallback((convId: number) => { - if (markReadTimerRef.current) clearTimeout(markReadTimerRef.current); - markReadTimerRef.current = setTimeout(() => { - markReadMutation.mutate(convId); - }, 300); - }, []); - - // 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. - // useLayoutEffect reports synchronously after DOM mutations so push suppression - // is active before any incoming message event is processed by the server. - useLayoutEffect(() => { - if (!activeConvId) return; - const visible = typeof document !== 'undefined' && document.visibilityState === 'visible'; - reportActiveChat('messenger', activeConvId, visible); - setActiveChatInSw('messenger', activeConvId, visible); - }, [activeConvId]); - - useEffect(() => { - if (!activeConvId) return; - - const report = (visible: boolean) => { - reportActiveChat('messenger', activeConvId, visible); - setActiveChatInSw('messenger', activeConvId, 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; - }, - }); - - // ── Typing indicator for bots ───────────────────────────────────────────────── - - const clearTypingBot = (botName: string) => { - const timer = typingTimersRef.current.get(botName); - if (timer) clearTimeout(timer); - typingTimersRef.current.delete(botName); - setTypingBots(prev => { - if (!prev.has(botName)) return prev; - const next = new Set(prev); - next.delete(botName); - return next; - }); - }; - - const addTypingBot = (botName: string) => { - const existing = typingTimersRef.current.get(botName); - if (existing) clearTimeout(existing); - setTypingBots(prev => { - if (prev.has(botName)) return prev; - const next = new Set(prev); - next.add(botName); - return next; - }); - typingTimersRef.current.set( - botName, - setTimeout(() => clearTypingBot(botName), 10000), - ); - }; - - useEffect(() => { - // Clear all typing indicators and pressed buttons when switching conversations - typingTimersRef.current.forEach(timer => clearTimeout(timer)); - typingTimersRef.current.clear(); - setTypingBots(new Set()); - setPressedButtons(new Set()); - }, [activeConvId]); - - // ── 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. Автоотметка прочтения только для активного, видимого чата и чужих сообщений. - // Оптимистично сбрасываем глобальный unread count, затем debounce'им запрос к серверу. - if (isActiveConv && !isMine && isVisible) { - queryClient.setQueryData<{ success: boolean; unreadCount: number }>(['/api/messenger/unread-count'], (old) => { - if (!old) return old; - return { ...old, unreadCount: 0 }; - }); - debouncedMarkRead(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] }; - } - ); - }, []), - - onConvTypingStarted: useCallback((data: { conversationId: number; botName: string }) => { - if (data.conversationId !== activeConvId) return; - addTypingBot(data.botName); - }, [activeConvId, addTypingBot]), - - onConvTypingStopped: useCallback((data: { conversationId: number; botName: string }) => { - if (data.conversationId !== activeConvId) return; - clearTypingBot(data.botName); - }, [activeConvId, clearTypingBot]), - - 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 botButtonCallbackMutation = useMutation({ - mutationFn: async ({ messageId, callbackData }: { messageId: number; callbackData: string }) => { - await apiRequest('POST', `/api/messenger/messages/${messageId}/callback`, { callbackData }); - }, - }); - - const handleSelectConv = (id: number) => { - setActiveConvId(id); - setReplyTo(null); - setEditingMsg(null); - setMessageText(''); - setSearchQuery(''); - markReadMutation.mutate(id); - if (isMobile) setMobileView('chat'); - setLocation(`/chat/${id}`); - // Report active chat immediately so the server can suppress pushes for this chat - // before any new message event reaches the notification service. - const visible = typeof document !== 'undefined' && document.visibilityState === 'visible'; - reportActiveChat('messenger', id, visible); - setActiveChatInSw('messenger', id, visible); - }; - - // ── Открытие/создание диалога из поиска ───────────────────────────────────── - - 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); + const { + user, + isMobile, + mobileView, + setMobileView, + searchInputRef, + searchQuery, + setSearchQuery, + newConvOpen, + setNewConvOpen, + searchResults, + debouncedSearch, + handleSearchSelectPerson, + handleSearchSelectGroup, + handleSearchSelectExternal, + handleSearchSelectBot, + convsLoading, + conversations, + activeConvId, + handleSelectConv, + muteMutation, + activeConv, + groupMembersOpen, + setGroupMembersOpen, + scrollRef, + msgsLoading, + messages, + hasMore, + loadMoreMessages, + isLoadingMore, + groupedMessages, + reactionsMap, + setReactionsMap, + pollsMap, + setPollsMap, + openDirectMutation, + openBotDirectMutation, + setPressedButtons, + botButtonCallbackMutation, + pressedButtons, + typingBots, + replyTo, + setReplyTo, + inputRef, + setEditingMsg, + setEditText, + deleteMutation, + editingMsg, + editText, + handleEditKeyDown, + editMutation, + mentionSearch, + mentionCandidates, + handleSelectMention, + pendingFiles, + setPendingFiles, + isDragOver, + handleFileSelect, + handleDrop, + handleDragOver, + handleDragLeave, + fileInputRef, + messageText, + handleMessageChange, + handleKeyDown, + handlePaste, + pollDialogOpen, + setPollDialogOpen, + handleSend, + sendMutation, + isUploading, + } = useChatController(); // ── Render ──────────────────────────────────────────────────────────────────── @@ -2511,24 +471,7 @@ export default function ChatPage() { {/* Bot typing indicator */} - {typingBots.size > 0 && ( -
- - 🤖 - -
- - {Array.from(typingBots).join(', ')} - - печатает - - - - - -
-
- )} + {/* Input area */}