Импорт
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
import { httpClient } from '../../../../core/infrastructure/httpClient';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { ChatApi } from '../../infrastructure/chatApi';
|
||||
import { getSocket } from '../../../../core/infrastructure/socket';
|
||||
@@ -52,6 +53,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
hasMoreMessages,
|
||||
loadMessages,
|
||||
setActiveChat,
|
||||
loadChats,
|
||||
} = useChatStore();
|
||||
|
||||
const [showTopMenu, setShowTopMenu] = useState(false);
|
||||
@@ -84,6 +86,36 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const chatMessages = allChatMessages.filter(m => !m.isDeleted);
|
||||
const pinnedMsg = activeChat ? pinnedMessages[activeChat] : null;
|
||||
|
||||
const [importStatus, setImportStatus] = useState<{ processed: number, total: number, status: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat?.isImporting || !chat?.importJobId) {
|
||||
setImportStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages, status: data.status });
|
||||
if (data.status === 'Completed' || data.status === 'Failed') {
|
||||
setImportStatus(null);
|
||||
loadChats();
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
console.warn('Import job not found');
|
||||
} else {
|
||||
console.error('Failed to poll status', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [chat?.isImporting, chat?.importJobId]);
|
||||
|
||||
// Количество непрочитанных сообщений (для бейджика)
|
||||
const unreadCount = chatMessages.filter(
|
||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||
@@ -115,17 +147,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
|
||||
}
|
||||
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
||||
const lastObservedMessageIdRef = useRef<string | null>(null);
|
||||
const initialScrollChatId = useRef<string | null>(null);
|
||||
const chatScrollPositionsRef = useRef<Record<string, number>>({});
|
||||
const visitedChatsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Load muted state
|
||||
useEffect(() => {
|
||||
if (activeChat) {
|
||||
setMuted(isChatMuted(activeChat));
|
||||
setActiveGroupCallParticipants([]);
|
||||
lastObservedMessageIdRef.current = null;
|
||||
}
|
||||
}, [activeChat]);
|
||||
|
||||
@@ -511,32 +539,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
if (!activeChat || !chat) {
|
||||
return (
|
||||
<section className="flex-1 h-full flex flex-col items-center justify-center bg-[#010101] relative overflow-hidden">
|
||||
{/* Background Knot Texture - Correct Horizontal Unclosed Infinity SVG */}
|
||||
{/* Background Knot Texture */}
|
||||
<div className="absolute inset-0 opacity-[0.04] pointer-events-none flex items-center justify-center">
|
||||
<svg
|
||||
width="1000"
|
||||
height="500"
|
||||
viewBox="0 0 600 300"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-[120vw] h-auto text-white select-none"
|
||||
>
|
||||
{/* Left part of the infinity loop */}
|
||||
<path
|
||||
d="M300 150 C 240 230 150 230 150 150 C 150 70 240 70 300 150"
|
||||
stroke="currentColor"
|
||||
strokeWidth="45"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Right part of the infinity loop with a small gap at the crossing */}
|
||||
<path
|
||||
d="M315 170 C 375 250 465 250 465 170 C 465 90 375 90 315 170"
|
||||
stroke="currentColor"
|
||||
strokeWidth="45"
|
||||
strokeLinecap="round"
|
||||
transform="translate(-15, -20)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span>
|
||||
</div>
|
||||
|
||||
{/* Chat Empty State View */}
|
||||
@@ -575,8 +580,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
);
|
||||
}
|
||||
|
||||
const initials = getInitials(chatName || '??');
|
||||
|
||||
const handleToggleSelect = (msgId: string) => {
|
||||
const newMap = new Set(selectedMessages);
|
||||
if (newMap.has(msgId)) {
|
||||
@@ -593,8 +596,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
setSelectedMessages(new Set([msgId]));
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleForward = (targetChatId: string) => {
|
||||
const socket = getSocket();
|
||||
if (!socket || !activeChat) return;
|
||||
@@ -655,7 +656,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-primary/5 to-transparent h-32 opacity-30" />
|
||||
{selectionMode ? (
|
||||
<div className="h-[76px] flex items-center justify-between px-6 bg-surface-container-highest/80 backdrop-blur-2xl z-20 flex-shrink-0 animate-in slide-in-from-top-2 border-none">
|
||||
<div className="h-[76px] flex items-center justify-between px-6 bg-surface-container-highest/80 backdrop-blur-xl z-20 flex-shrink-0 animate-in slide-in-from-top-2 border-none">
|
||||
<div className="flex items-center gap-4 text-on-surface">
|
||||
<button onClick={() => { setSelectionMode(false); setSelectedMessages(new Set()); }} className="p-2 -ml-2 rounded-xl hover:bg-on-surface/10 transition slide-on-ice">
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
@@ -985,176 +986,218 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Закреплённое сообщение */}
|
||||
{/* Active group call banner */}
|
||||
{chat?.type === 'group' && config?.webRtc?.enabled && activeGroupCallParticipants.length > 0 && (
|
||||
<button
|
||||
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Phone size={14} className="text-emerald-400" />
|
||||
{chat.isImporting ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-surface-container-lowest relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-[0.02] pointer-events-none flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-emerald-400">{t('activeCall')}</p>
|
||||
<p className="text-sm text-zinc-300">{activeGroupCallParticipants.length} {t('participants')}</p>
|
||||
</div>
|
||||
<span className="text-xs text-emerald-400 font-medium px-3 py-1 rounded-full bg-emerald-500/20">{t('joinCall')}</span>
|
||||
</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-border bg-surface-secondary/60 hover:bg-surface-hover transition-colors text-left w-full flex-shrink-0"
|
||||
>
|
||||
<Pin size={16} className="text-knot-400 flex-shrink-0 rotate-45" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-knot-400">{t('pinnedMessage')}</p>
|
||||
<p className="text-sm text-zinc-300 truncate">
|
||||
{pinnedMsg.content || (pinnedMsg.media?.length > 0 ? t('media') : '...')}
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
className="z-10 w-full max-w-md p-8 rounded-[2.5rem] bg-surface-container-low border border-outline/10 shadow-2xl backdrop-blur-3xl"
|
||||
>
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-3xl bg-primary/10 flex items-center justify-center shadow-lg shadow-primary/5">
|
||||
<span className="material-symbols-outlined text-primary text-4xl animate-bounce">downloading</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-black text-on-surface tracking-tight mb-2">
|
||||
Идет импорт истории
|
||||
</h2>
|
||||
<p className="text-on-surface-variant text-sm font-medium mb-8 opacity-60 leading-relaxed">
|
||||
Мы переносим ваши сообщения и медиафайлы из Telegram. Это займет некоторое время.
|
||||
</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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Сообщения */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
|
||||
>
|
||||
{isLoadingMessages && chatMessages.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : chatMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-zinc-500">{t('noMessages')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-w-3xl mx-auto">
|
||||
{isLoadingMessages && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-black uppercase tracking-widest text-primary">
|
||||
{importStatus?.status === 'Processing' ? 'Обработка' :
|
||||
importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'}
|
||||
</span>
|
||||
<span className="text-xs font-black text-on-surface tabular-nums">
|
||||
{importStatus?.processed || 0} / {importStatus?.total || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden border border-outline/10 p-0.5">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.min(100, Math.round(((importStatus?.processed || 0) / (importStatus?.total || 1)) * 100))}%` }}
|
||||
transition={{ type: 'spring', damping: 20 }}
|
||||
className="h-full bg-linear-to-r from-primary to-primary-container rounded-full shadow-[0_0_20px_rgba(48,150,229,0.3)] transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] font-bold text-on-surface-variant/40 uppercase tracking-[0.2em] pt-4">
|
||||
Чат станет доступен автоматически
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{chat?.type === 'group' && config?.webRtc?.enabled && activeGroupCallParticipants.length > 0 && (
|
||||
<button
|
||||
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-outline/10 bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Phone size={14} className="text-emerald-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-emerald-400">{t('activeCall')}</p>
|
||||
<p className="text-sm text-zinc-300">{activeGroupCallParticipants.length} {t('participants')}</p>
|
||||
</div>
|
||||
<span className="text-xs text-emerald-400 font-medium px-3 py-1 rounded-full bg-emerald-500/20">{t('joinCall')}</span>
|
||||
</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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
|
||||
>
|
||||
{isLoadingMessages && chatMessages.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : chatMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-4 opacity-30 select-none">
|
||||
<MessagesSquare size={64} className="text-on-surface-variant" />
|
||||
<p className="text-sm font-bold uppercase tracking-widest text-on-surface-variant">{t('noMessages')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-w-3xl mx-auto">
|
||||
{isLoadingMessages && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{chatMessages.map((msg, i) => {
|
||||
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
|
||||
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
|
||||
const showDate =
|
||||
!prevMsg ||
|
||||
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
|
||||
|
||||
const isFirstUnread = firstUnreadId === msg.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
data-message-id={msg.id}
|
||||
data-sequence-id={msg.sequenceId}
|
||||
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
|
||||
>
|
||||
{isFirstUnread && (
|
||||
<div id="unread-divider" className="flex items-center justify-center my-4 opacity-80 select-none">
|
||||
<div className="flex-1 h-px bg-outline/20"></div>
|
||||
<span className="px-4 text-[11px] font-semibold tracking-wider uppercase text-zinc-400">
|
||||
{t('unreadMessages')}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-outline/20"></div>
|
||||
</div>
|
||||
)}
|
||||
{showDate && (
|
||||
<div className="flex justify-center my-4">
|
||||
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass-effect">
|
||||
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
isMine={msg.senderId === user?.id}
|
||||
showAvatar={showAvatar}
|
||||
onViewProfile={(userId) => setProfileUserId(userId)}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedMessages.has(msg.id)}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onStartSelectionMode={handleStartSelection}
|
||||
onForward={(id) => {
|
||||
setSelectedMessages(new Set([id]));
|
||||
setShowForwardModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} className="h-4" />
|
||||
</div>
|
||||
)}
|
||||
{chatMessages.map((msg, i) => {
|
||||
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
|
||||
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
|
||||
const showDate =
|
||||
!prevMsg ||
|
||||
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
|
||||
|
||||
const isFirstUnread = firstUnreadId === msg.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
data-message-id={msg.id}
|
||||
data-sequence-id={msg.sequenceId}
|
||||
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
|
||||
>
|
||||
{isFirstUnread && (
|
||||
<div id="unread-divider" className="flex items-center justify-center my-4 opacity-80 select-none">
|
||||
<div className="flex-1 h-px bg-border/50"></div>
|
||||
<span className="px-4 text-[11px] font-semibold tracking-wider uppercase text-zinc-400">
|
||||
{t('unreadMessages')}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/50"></div>
|
||||
</div>
|
||||
)}
|
||||
{showDate && (
|
||||
<div className="flex justify-center my-4">
|
||||
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass">
|
||||
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
isMine={msg.senderId === user?.id}
|
||||
showAvatar={showAvatar}
|
||||
onViewProfile={(userId) => setProfileUserId(userId)}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedMessages.has(msg.id)}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onStartSelectionMode={handleStartSelection}
|
||||
onForward={(id) => {
|
||||
setSelectedMessages(new Set([id]));
|
||||
setShowForwardModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} className="h-4" /> {/* Empty spacer for the bottom scroll boundary */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка прокрутки вниз */}
|
||||
<AnimatePresence>
|
||||
{showScrollDown && (
|
||||
<motion.button
|
||||
initial={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => {
|
||||
scrollToBottom(true);
|
||||
if (activeChat && unreadCount > 0) {
|
||||
useChatStore.getState().markAllAsRead(activeChat);
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-24 right-8 w-14 h-14 rounded-2xl bg-gradient-to-br from-primary to-primary-container text-on-primary-container shadow-[0_8px_30px_rgba(48,150,229,0.3)] flex items-center justify-center transition-all z-10 border border-white/10 backdrop-blur-md"
|
||||
>
|
||||
<span className="material-symbols-outlined text-3xl">arrow_downward</span>
|
||||
{unreadCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -top-2 -right-2 min-w-[24px] h-6 px-1.5 rounded-full bg-error text-on-error text-[12px] font-black flex items-center justify-center shadow-lg border-2 border-surface-container-lowest"
|
||||
<AnimatePresence>
|
||||
{showScrollDown && (
|
||||
<motion.button
|
||||
initial={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => scrollToBottom(true)}
|
||||
className="absolute bottom-24 right-8 w-14 h-14 rounded-2xl bg-primary text-on-primary shadow-2xl flex items-center justify-center z-20 hover:shadow-primary/20 transition-all"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</motion.span>
|
||||
<ArrowDown size={28} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-2 -right-2 min-w-[24px] h-6 px-1.5 rounded-full bg-error text-on-error text-[12px] font-black flex items-center justify-center shadow-lg border-2 border-surface-container-lowest">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>
|
||||
|
||||
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe">
|
||||
<MessageInput chatId={activeChat} />
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Typing индикатор */}
|
||||
{typingInChat.length > 0 && (
|
||||
<div className="px-4 pb-1">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ввод сообщения */}
|
||||
{activeChat && <MessageInput chatId={activeChat} />}
|
||||
|
||||
{(() => {
|
||||
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
|
||||
cleanup?.();
|
||||
@@ -1173,29 +1216,23 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
if (!activeChat) return;
|
||||
|
||||
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('info', t('searchingHistory' as any) || 'Searching message in history...');
|
||||
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;
|
||||
|
||||
// Smart timeline-based search
|
||||
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 target is newer than oldest loaded, and not found, maybe it's in a gap or we need to keep loading?
|
||||
// Actually target is almost always older if not found.
|
||||
// If we don't have targetCreatedAt, we guess (up to 100 attempts)
|
||||
if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) {
|
||||
// Should have been found by tryScroll, but lets try one last time
|
||||
if (tryScroll()) { found = true; break; }
|
||||
}
|
||||
|
||||
if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break;
|
||||
|
||||
await chatStore.loadMessages(activeChat, false, true);
|
||||
// Give React 150ms to render the new messages
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
if (tryScroll()) {
|
||||
@@ -1203,21 +1240,18 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
break;
|
||||
}
|
||||
|
||||
// Stop if we have gone way past the target date
|
||||
if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
|
||||
// We are 1 hour before the message and still haven't found it? might be deleted
|
||||
if (i > 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', t('messageNotFound' as any) || 'Message not found');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Профиль пользователя */}
|
||||
<AnimatePresence>
|
||||
{profileUserId && (
|
||||
<UserProfile
|
||||
@@ -1230,7 +1264,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Настройки группы */}
|
||||
<AnimatePresence>
|
||||
{showGroupSettings && chat && chat.type === 'group' && (
|
||||
<GroupSettings
|
||||
|
||||
Reference in New Issue
Block a user