Пункты 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-стейты.
1161 lines
47 KiB
TypeScript
1161 lines
47 KiB
TypeScript
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,
|
||
};
|
||
}
|