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