Files
iistwin/client/src/components/chat/useChatController.ts
Ильяс Султанов 06a2794090 refactor(chat): общие ChatComposer и useChatScroll для чата задачи и мессенджера
Пункты 1–2 плана де-дубликации чатов — больше не нужно «чинить и там и там»:
- components/chat/ChatComposer.tsx: textarea + меню трёх точек (Прикрепить файл/
  Создать опрос) + кнопка отправки, Enter-отправка (в PWA — только кнопкой),
  paste/drag&drop файлов, спиннер отправки; специфика клавиш родителя
  (упоминания, Escape) через onKeyDown + defaultPrevented-протокол;
- components/chat/useChatScroll.ts: pin к низу при открытии (useLayoutEffect до
  paint), автоскролл у низа/своё, тихая доставка на скрытой вкладке, счётчик
  «↓ N новых», pin при возврате на вкладку;
- TaskChat и useChatController/Chat.tsx переведены на оба; удалены дубли
  (~200 строк): свои scrollToBottom/atBottomRef/lastSeenLengthRef/эффекты,
  свои формы композера, drag&drop-стейты.
2026-09-30 14:37:31 +03:00

1161 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useLocation, useParams } from 'wouter';
import { useVirtualizer } from '@tanstack/react-virtual';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { saveCachedMessages, getCachedMessages } from '@/lib/messageCache';
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 { usePersistentDraft } from '@/hooks/usePersistentDraft';
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, flattenGroupedMessages, useLastAppendedMessageId, CHAT_VIRTUALIZATION_THRESHOLD, type ChatVirtualItem } from './chat-utils';
import { useMessagePolling } from './useMessagePolling';
import { useChatScroll } from './useChatScroll';
// ── 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<number | null>(null);
const [newConvOpen, setNewConvOpen] = useState(false);
const [groupMembersOpen, setGroupMembersOpen] = useState(false);
const [replyTo, setReplyTo] = useState<ConvMessage | null>(null);
const [editingMsg, setEditingMsg] = useState<ConvMessage | null>(null);
// Черновик персистентный: переживает перезагрузку страницы (SW-деплой, редирект авторизации)
const [messageText, setMessageText] = usePersistentDraft(`draft:conv:${activeConvId ?? 'none'}`);
const [editText, setEditText] = useState('');
const [mentionedUserIds, setMentionedUserIds] = useState<number[]>([]);
const [mentionSearch, setMentionSearch] = useState<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Reactions & polls
const [reactionsMap, setReactionsMap] = useState<Record<number, ReactionGroup[]>>({});
const [pollsMap, setPollsMap] = useState<Record<number, PollData>>({});
const [pollDialogOpen, setPollDialogOpen] = useState(false);
// Search
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
// Typing indicator for bots
const [typingBots, setTypingBots] = useState<Set<string>>(new Set());
const typingTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Pressed bot buttons (messageId:callbackData)
const [pressedButtons, setPressedButtons] = useState<Set<string>>(new Set());
// File attachments
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
// Tracks IndexedDB queue item IDs that are still pending. Used by the select
// function to keep optimistic messages after server refetches.
const activeQueueIdsRef = useRef<Set<string>>(new Set());
const activeAttachmentIdsRef = useRef<Set<string>>(new Set());
const markReadTimerRef = useRef<ReturnType<typeof setTimeout> | 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<ConvMessage[]>([]);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Мобильный / PWA режим
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768 || isPWAInstalled());
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)');
const update = () => setIsMobile(mq.matches || isPWAInstalled());
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
// Debounce поиска
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchQuery), 200);
return () => clearTimeout(t);
}, [searchQuery]);
// ── Queries ──────────────────────────────────────────────────────────────────
const { data: searchData } = useQuery<{
success: boolean;
people: { id: number; firstName: string; lastName: string; middleName?: string; position?: string | null }[];
groups: { id: number; name: string }[];
external: { id: number; firstName: string; lastName: string; middleName?: string; position?: string | null; organizationId: number; connectionId: number }[];
bots: { id: number; name: string; login: string; avatarUrl?: string | null }[];
}>({
queryKey: ['/api/messenger/search', debouncedSearch],
queryFn: async () => {
if (!debouncedSearch.trim()) return { success: true, people: [], groups: [], external: [], bots: [] };
const res = await apiRequest('GET', `/api/messenger/search?q=${encodeURIComponent(debouncedSearch)}`);
return res.json();
},
enabled: debouncedSearch.trim().length > 0,
staleTime: 10000,
});
const searchResults = (searchQuery.trim() && debouncedSearch.trim()) ? searchData : null;
const { data: convsData, isLoading: convsLoading } = useQuery<{ success: boolean; conversations: Conversation[] }>({
queryKey: ['/api/messenger/conversations'],
refetchInterval: false,
});
const conversations = convsData?.conversations ?? [];
// Автооткрытие чата при переходе по push deep-link (/chat/:conversationId)
useEffect(() => {
if (!params?.conversationId) return;
const targetId = parseInt(params.conversationId, 10);
if (isNaN(targetId)) return;
setActiveConvId(targetId);
if (isMobile) setMobileView('chat');
}, [params?.conversationId, conversations.length]);
// Auto-mark read when a conversation is opened (including from URL deep-link)
useEffect(() => {
if (!activeConvId) return;
markReadMutation.mutate(activeConvId);
}, [activeConvId]);
// Auto-mark read when user returns to the tab/window with an open conversation
useEffect(() => {
if (!activeConvId) return;
const handleVisibility = () => {
if (document.visibilityState === 'visible') {
markReadMutation.mutate(activeConvId);
}
};
const handleFocus = () => {
markReadMutation.mutate(activeConvId);
};
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', handleFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', handleFocus);
};
}, [activeConvId]);
// Report active chat to server and SW so push is suppressed while this chat is open.
// 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,
staleTime: 10000, // Данные свежие 10 секунд; восстановленный из localStorage кэш старше → фоновый refetch
gcTime: Infinity, // Кэш не удалять при размонтировании — возврат в чат без «Загрузка...»
// Персистентный кэш: мгновенный рендер последних сообщений после перезагрузки
// страницы, свежие данные догружаются в фоне (initialDataUpdatedAt старше
// staleTime → фоновый refetch сразу после монтирования)
initialData: () => {
if (!activeConvId) return undefined;
const cached = getCachedMessages<ConvMessage>(`conv:${activeConvId}`);
return cached ? { success: true, messages: cached.messages } : undefined;
},
initialDataUpdatedAt: () => {
if (!activeConvId) return undefined;
return getCachedMessages<ConvMessage>(`conv:${activeConvId}`)?.savedAt;
},
// 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 ?? [];
// Сохраняем последние сообщения в localStorage — для мгновенного рендера
// после перезагрузки страницы (см. initialData в useQuery выше)
useEffect(() => {
if (activeConvId && Array.isArray(msgsData?.messages) && msgsData.messages.length > 0) {
saveCachedMessages(`conv:${activeConvId}`, msgsData.messages);
}
}, [msgsData, activeConvId]);
// 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];
// ── Список сообщений: группировка по дате + виртуализация ────────────────────
const groupedMessages = groupByDate(messages);
// Id последнего добавленного сообщения — для анимации появления только его
const lastAppendedId = useLastAppendedMessageId(messages, activeConvId);
// Плоский список для виртуализации: разделители дат + сообщения
const chatItems: ChatVirtualItem<ConvMessage>[] = flattenGroupedMessages(groupedMessages, (m) => `msg-${m.id}`);
// Виртуализация длинных чатов (по образцу TaskChat): рендерятся только видимые
// сообщения + overscan. Динамическая высота измеряется через measureElement.
const shouldVirtualize = chatItems.length > CHAT_VIRTUALIZATION_THRESHOLD;
const chatVirtualizer = useVirtualizer({
count: chatItems.length,
getScrollElement: () =>
(scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null) ?? null,
estimateSize: (index) => (chatItems[index]?.type === 'header' ? 40 : 72),
overscan: 10,
getItemKey: (index) => chatItems[index]?.key ?? index,
enabled: shouldVirtualize,
});
// Скролл чата (pin к низу, «↓ N новых», тихая доставка на скрытой вкладке,
// первый кадр сразу у последнего сообщения) — общий хук обоих чатов
const { newBelowCount, scrollToBottomAndClear } = useChatScroll({
scrollRef,
virtualizer: shouldVirtualize ? chatVirtualizer : null,
itemCount: chatItems.length,
messageCount: initialMessages.length,
lastMessageIsMine: initialMessages[initialMessages.length - 1]?.author?.id === user?.id,
isLoading: msgsLoading,
resetKey: activeConvId,
});
// 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 не доставил событие, раз в 5 секунд
// запрашиваем только новые сообщения после последнего известного id.
useMessagePolling<ConvMessage>({
queryKey: ['/api/messenger/conversations', activeConvId, 'messages'],
buildUrl: (afterId) => `/api/messenger/conversations/${activeConvId}/messages?afterId=${afterId}&limit=50`,
enabled: !!activeConvId && !!user,
});
// ── 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);
// Черновик не очищаем: usePersistentDraft восстановит сохранённый текст этого диалога
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 | File[] | null) => {
if (!files) return;
setPendingFiles(prev => [...prev, ...Array.from(files)].slice(0, 10));
};
const sendMutation = useMutation({
mutationFn: async (payload: { message: string; replyToId?: number; mentionedUserIds?: number[]; attachments?: ChatAttachment[]; clientMessageId?: string }) => {
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 = (val: string) => {
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,
// Ключ идемпотентности: реплеи офлайн-очереди (в т.ч. из разных вкладок)
// идут с одним ключом → серверный дедуп по client_message_id (миграция 0087)
clientMessageId: crypto.randomUUID(),
});
setMentionedUserIds([]);
setMentionSearch(null);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && mentionSearch !== null) {
e.preventDefault();
setMentionSearch(null);
return;
}
// Отправка по Enter — внутри ChatComposer; при открытых подсказках
// упоминаний Enter блокируем, чтобы не отправить сообщение случайно
if (e.key === 'Enter' && mentionSearch !== null) {
e.preventDefault();
}
};
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && editingMsg) {
// В PWA/standalone-режиме Enter вставляет новую строку, сохранение — только кнопкой
if (isPWAInstalled()) return;
e.preventDefault();
if (editText.trim()) editMutation.mutate({ msgId: editingMsg.id, message: editText.trim() });
}
if (e.key === 'Escape') {
setEditingMsg(null);
setEditText('');
}
};
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,
newBelowCount,
scrollToBottomAndClear,
hasMore,
loadMoreMessages,
isLoadingMore,
groupedMessages,
chatItems,
shouldVirtualize,
chatVirtualizer,
lastAppendedId,
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,
handleFileSelect,
messageText,
handleMessageChange,
handleKeyDown,
pollDialogOpen,
setPollDialogOpen,
handleSend,
sendMutation,
isUploading,
};
}