Заготовка опросов

This commit is contained in:
Халимов Рустам
2026-04-07 01:01:29 +03:00
parent 02a85fc587
commit c45f4db61c
23 changed files with 824 additions and 249 deletions
@@ -77,7 +77,58 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
cleanup?.();
const tryScroll = () => {
const el = document.getElementById(`msg-${msgId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
return true;
}
return false;
};
if (tryScroll()) return;
if (!activeChat) return;
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...');
const chatStore = useChatStore.getState();
let found = false;
const targetDate = targetCreatedAt ? new Date(targetCreatedAt).getTime() : 0;
for (let i = 0; i < 100; i++) {
const chatMessages = chatStore.messages[activeChat] || [];
const oldestLoaded = chatMessages.length > 0 ? new Date(chatMessages[0].createdAt).getTime() : Date.now();
if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) {
if (tryScroll()) { found = true; break; }
}
if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break;
await chatStore.loadMessages(activeChat, false, true);
await new Promise(resolve => setTimeout(resolve, 150));
if (tryScroll()) {
found = true;
break;
}
if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
if (i > 10) break;
}
}
if (!found) {
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
}
};
const searchInputRef = useRef<HTMLInputElement>(null);
const topMenuRef = useRef<HTMLDivElement>(null);
const deleteMenuRef = useRef<HTMLDivElement>(null);
@@ -87,7 +138,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const allChatMessages = activeChat ? messages[activeChat] || [] : [];
// Filter out deleted messages to prevent layout shifts
const chatMessages = allChatMessages.filter(m => !m.isDeleted);
const pinnedMsg = activeChat ? pinnedMessages[activeChat] : null;
const chatPinnedMessages = activeChat ? pinnedMessages[activeChat] || [] : [];
const [pinnedIndex, setPinnedIndex] = useState(0);
const [importStatus, setImportStatus] = useState<{ processed: number, total: number, status: string } | null>(null);
const isAtBottomRef = useRef(false);
@@ -213,8 +265,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const scrollToBottom = useCallback((smooth = true) => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
} else if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
} else if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
}, []);
@@ -225,7 +277,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (ЯКОРНОЕ ПО MESSAGE ID)
const saveScrollPosition = useCallback((targetChatId?: string) => {
const container = messagesContainerRef.current;
const container = scrollContainerRef.current;
const chatId = targetChatId || activeChat;
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
@@ -233,7 +285,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Проверка: сообщения в стейте должны быть от целевого чата
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
isAtBottomRef.current = isAtBottomNow;
if (isAtBottomNow) {
@@ -268,7 +320,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ
const restoreScrollPosition = useCallback(() => {
const container = messagesContainerRef.current;
const container = scrollContainerRef.current;
if (!container || !activeChat) return false;
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false;
@@ -323,8 +375,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// 3. ОБЗЕРВЕР И УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ
useEffect(() => {
if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return;
const container = messagesContainerRef.current;
if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return;
const container = scrollContainerRef.current;
const observer = new ResizeObserver(() => {
if (isInitializingRef.current) return;
@@ -361,15 +413,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
isScrollingToBottomRef.current = false;
// Сохраняем позицию старого чата
if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) {
if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) {
saveScrollPosition(prevChatIdRef.current);
}
setScrollReady(false);
prevChatIdRef.current = activeChat;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = 0;
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
const timer = setTimeout(() => {
@@ -381,7 +433,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}, [activeChat, saveScrollPosition, scrollReady]);
const checkScrollPosition = useCallback(() => {
const container = messagesContainerRef.current;
const container = scrollContainerRef.current;
if (!container || isInitializingRef.current) return;
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
setShowScrollDown(!isNearBottom);
@@ -392,10 +444,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
checkScrollPosition();
const container = messagesContainerRef.current;
const container = scrollContainerRef.current;
if (container && activeChat) {
// Synchronous "at bottom" check to prevent ResizeObserver from fighting the user
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
isAtBottomRef.current = isAtBottomNow;
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
@@ -405,12 +457,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const isScrollingUp = st < lastScrollTopRef.current;
lastScrollTopRef.current = st;
// Sticky Date Header Logic - Telegram style: show when scrolling, especially up
if (st > 100) {
// Sticky Date Header Logic - Telegram style
if (st > 100 && (isScrollingUp || st !== lastScrollTopRef.current)) {
setShowStickyDate(true);
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 2000);
} else {
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), isScrollingUp ? 1500 : 1000);
} else if (st <= 100) {
setShowStickyDate(false);
}
@@ -451,9 +503,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
localStorage.removeItem(`chat_anchor_${activeChat}`);
isScrollingToBottomRef.current = true;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}
@@ -514,7 +566,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}
},
{
root: messagesContainerRef.current,
root: scrollContainerRef.current,
threshold: 0.1,
}
);
@@ -522,9 +574,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
observerRef.current = observer;
const observeUnread = () => {
if (!messagesContainerRef.current) return;
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el) => {
if (!scrollContainerRef.current) return;
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el: Element) => {
const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) {
observer.observe(el);
@@ -537,9 +589,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}, [activeChat, user?.id]);
useEffect(() => {
if (observerRef.current && messagesContainerRef.current) {
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el) => {
if (observerRef.current && scrollContainerRef.current) {
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el: Element) => {
const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) {
observerRef.current?.observe(el);
@@ -949,6 +1001,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
try {
await ChatApi.clearChat(activeChat);
useChatStore.getState().clearMessages(activeChat);
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
NotificationStore.useNotificationStore.getState().addNotification('success', t('chatCleared'));
} catch (e) {
console.error(e);
}
@@ -1100,37 +1154,62 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</button>
)}
{pinnedMsg && (
<button
onClick={() => {
const el = document.getElementById(`msg-${pinnedMsg.id}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
}
}}
className="flex items-center gap-3 px-4 py-2 border-b border-outline/10 bg-surface-container-high/60 hover:bg-surface-container-highest transition-colors text-left w-full flex-shrink-0"
>
<Pin size={16} className="text-primary flex-shrink-0 rotate-45" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-primary">{t('pinnedMessage')}</p>
<p className="text-sm text-zinc-300 truncate">
{pinnedMsg.content || (pinnedMsg.media?.length > 0 ? t('media') : '...')}
</p>
</div>
<X
size={16}
className="text-zinc-500 hover:text-white flex-shrink-0 transition-colors"
onClick={(e) => {
e.stopPropagation();
const socket = getSocket();
if (socket && activeChat) {
socket.emit('unpin_message', { messageId: pinnedMsg.id, chatId: activeChat });
{chatPinnedMessages.length > 0 && (
<div className="flex-shrink-0 flex items-center gap-0 border-b border-outline/10 bg-surface-container-high/60 hover:bg-surface-container-high transition-colors overflow-hidden h-[54px] relative">
{/* Cycling progress indicator for multiple pins */}
{chatPinnedMessages.length > 1 && (
<div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden">
{chatPinnedMessages.map((_, idx) => (
<div
key={idx}
className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`}
/>
))}
</div>
)}
<button
onClick={() => {
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
if (!currentPin) return;
handleJumpToMessage(currentPin.id, undefined, currentPin.createdAt);
if (chatPinnedMessages.length > 1) {
setPinnedIndex(prev => (prev + 1) % chatPinnedMessages.length);
}
}}
/>
</button>
className={`flex-1 flex items-center gap-3 px-4 py-2 text-left h-full ${chatPinnedMessages.length > 1 ? 'ml-1.5' : ''}`}
>
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Pin size={14} className="text-primary rotate-45" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-black text-primary uppercase tracking-wider">
{t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''}
</p>
<p className="text-sm text-zinc-300 truncate font-medium">
{chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content ||
(chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')}
</p>
</div>
</button>
<div className="flex items-center px-2">
<button
onClick={(e) => {
e.stopPropagation();
const socket = getSocket();
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
if (socket && activeChat && currentPin) {
socket.emit('unpin_message', { messageId: currentPin.id, chatId: activeChat });
}
}}
className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-500 hover:text-white hover:bg-white/5 transition-all"
title={t('unpin' as any) || 'Открепить'}
>
<X size={16} />
</button>
</div>
</div>
)}
<div className="relative flex-1 flex flex-col overflow-hidden">
@@ -1142,7 +1221,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
exit={{ opacity: 0, scale: 0.9, y: -20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
key="sticky-date"
className="absolute top-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-none"
className="absolute top-6 left-1/2 -translate-x-1/2 z-[999] pointer-events-none"
>
<span className="px-4 py-1.5 rounded-full text-[11px] font-black uppercase tracking-widest text-white bg-black/60 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)] border border-white/10 ring-2 ring-black/20 whitespace-nowrap">
{stickyDate}
@@ -1152,7 +1231,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</AnimatePresence>
<div
ref={messagesContainerRef}
ref={scrollContainerRef}
onScroll={handleScroll}
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 scroll-smooth-container ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
>
@@ -1253,7 +1332,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</AnimatePresence>
</div>
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe">
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe relative z-50">
<MessageInput chatId={activeChat} />
</footer>
</>
@@ -1266,57 +1345,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
)}
{(() => {
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
cleanup?.();
const tryScroll = () => {
const el = document.getElementById(`msg-${msgId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
return true;
}
return false;
};
if (tryScroll()) return;
if (!activeChat) return;
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...');
const chatStore = useChatStore.getState();
let found = false;
const targetDate = targetCreatedAt ? new Date(targetCreatedAt).getTime() : 0;
for (let i = 0; i < 100; i++) {
const chatMessages = chatStore.messages[activeChat] || [];
const oldestLoaded = chatMessages.length > 0 ? new Date(chatMessages[0].createdAt).getTime() : Date.now();
if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) {
if (tryScroll()) { found = true; break; }
}
if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break;
await chatStore.loadMessages(activeChat, false, true);
await new Promise(resolve => setTimeout(resolve, 150));
if (tryScroll()) {
found = true;
break;
}
if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
if (i > 10) break;
}
}
if (!found) {
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
}
};
return (
<>
<AnimatePresence>