Импорт

This commit is contained in:
Халимов Рустам
2026-04-06 22:20:13 +03:00
parent fa185afc73
commit 1558b20470
317 changed files with 18311 additions and 924 deletions
@@ -123,7 +123,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ isLoadingMessages: true });
const currentMessages = state.messages[chatId] || [];
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].createdAt : undefined;
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
const fetched = await ChatApi.getMessages(chatId, cursor);
@@ -132,12 +132,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
const existing = reset ? [] : (state.messages[chatId] || []);
const fetchedIds = new Set(fetched.map(m => m.id));
const socketOnly = existing.filter(m => !fetchedIds.has(m.id));
const merged = [...fetched, ...socketOnly].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const merged = [...fetched, ...socketOnly].sort((a, b) => {
const tDiff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
return tDiff !== 0 ? tDiff : a.sequenceId - b.sequenceId;
});
return {
messages: { ...state.messages, [chatId]: merged },
hasMoreMessages: { ...state.hasMoreMessages, [chatId]: fetched.length === 100 },
hasMoreMessages: { ...state.hasMoreMessages, [chatId]: fetched.length >= 50 },
isLoadingMessages: false,
};
});
@@ -4,6 +4,7 @@ import { ru, enUS } from 'date-fns/locale';
import { Check, CheckCheck, Image, FileText, Mic, Video, Pin, Trash2, Bookmark } from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore';
import { httpClient } from '../../../../core/infrastructure/httpClient';
import { useLang } from '../../../../core/infrastructure/i18n';
import { stripMarkdown } from '../../../../core/utils/utils';
import { ChatApi } from '../../infrastructure/chatApi';
@@ -72,20 +73,54 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
: lastMessage.content || ''
: '';
const previewText = stripMarkdown(lastMessageText);
const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText);
const isMine = lastMessage?.senderId === user?.id;
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
// Галочки прочтения
const isRead = lastMessage?.readBy?.some((r) => r.userId !== user?.id);
const isRead = !chat.isImporting && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
const timeStr = lastMessage
const timeStr = !chat.isImporting && lastMessage
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
: '';
const [showAttachmentConfirm, setShowAttachmentConfirm] = useState(false);
const [importStatus, setImportStatus] = useState<{ processed: number, total: number } | 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 });
if (data.status === 'Completed' || data.status === 'Failed') {
loadChats();
}
} catch (e: any) {
if (e.status === 404) {
// Job might have expired or backend restarted
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
}
};
poll();
const interval = setInterval(poll, 1500);
return () => clearInterval(interval);
}, [chat.isImporting, chat.importJobId, loadChats]);
const handleClick = () => {
if (chat.isImporting) {
// Here we could show a progress modal, but for now just select it
setActiveChat(chat.id);
return;
}
if ((window as any).hasUnsavedAttachments && !isActive) {
setShowAttachmentConfirm(true);
return;
@@ -188,9 +223,24 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
</span>
</span>
)}
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
</p>
<div className="flex flex-col gap-1 w-full min-w-0">
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
{chat.isImporting && importStatus && importStatus.total > 0 && (
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
{importStatus.processed} / {importStatus.total}
</span>
)}
</p>
{chat.isImporting && importStatus && importStatus.total > 0 && (
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
/>
</div>
)}
</div>
</div>
{chat.unreadCount > 0 && !isActive && (
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-primary text-[#0a0a0a] flex items-center justify-center text-[10px] font-black shadow-lg shadow-primary/20 animate-pulse">
@@ -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
@@ -353,8 +353,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
{/* Avatar */}
<div className="flex-shrink-0 flex flex-col items-center py-6 px-6 overflow-y-auto max-h-[50%] custom-scrollbar">
<div className="relative group">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-knot-500/20 rounded-full blur-[40px] pointer-events-none" />
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-knot-500/20 rounded-[3rem] blur-[40px] pointer-events-none" />
<div className="relative z-10 p-1.5 rounded-[2.5rem] bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
<Avatar
src={chat.avatar ? getMediaUrl(chat.avatar) : null}
name={chat.name || '?'}
@@ -655,14 +655,22 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
}}
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
>
<video
src={getMediaUrl(m.url)}
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{(m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? (
<img
src={getMediaUrl(m.url)}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<video
src={getMediaUrl(m.url)}
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
)}
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
@@ -347,7 +347,7 @@ function MessageBubble({
const isMediaGif = (m: MediaItem) => {
if (m.type === 'gif') return true;
if (m.url?.toLowerCase().includes('klipy') || m.url?.toLowerCase().endsWith('.gif')) return true;
if (m.filename?.toLowerCase().includes('gif')) return true;
if (m.filename?.toLowerCase().includes('gif') || m.filename?.toLowerCase().endsWith('.mp4') || m.filename?.toLowerCase().endsWith('.gif')) return true;
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return true;
return false;
};
@@ -643,12 +643,20 @@ function MessageBubble({
className={`relative cursor-pointer group/video overflow-hidden transition-all hover:brightness-90 bg-zinc-900 ${cellClass}`}
onClick={() => setLightboxData({ index: idx })}
>
{gif && m.url?.toLowerCase().endsWith('.mp4') ? (
<video
src={getMediaUrl(m.url)}
autoPlay loop muted playsInline preload="metadata"
className={`w-full h-full object-cover ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
/>
{gif ? (
(m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? (
<img
src={getMediaUrl(m.url)}
alt=""
className={`w-full h-full object-cover ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
/>
) : (
<video
src={getMediaUrl(m.url)}
autoPlay loop muted playsInline preload="metadata"
className={`w-full h-full object-cover min-h-[150px] min-w-[200px] bg-zinc-900 shadow-inner rounded-[1.25rem] ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
/>
)
) : m.type === 'video' ? (
<>
{m.thumbnail ? (
@@ -13,11 +13,12 @@ import { getInitials } from '../../../../core/utils/utils';
interface NewChatModalProps {
onClose: () => void;
onOpenTelegramImport?: () => void;
}
type Mode = 'personal' | 'group-select' | 'group-name';
export default function NewChatModal({ onClose }: NewChatModalProps) {
export default function NewChatModal({ onClose, onOpenTelegramImport }: NewChatModalProps) {
const { user, config } = useAuthStore();
const { t } = useLang();
const { addChat, setActiveChat, loadMessages } = useChatStore();
@@ -225,7 +226,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
onClick={() => setMode('group-select')}
className="w-full flex items-center gap-4 px-4 py-3.5 rounded-2xl bg-surface-container-highest/20 hover:bg-surface-container-highest/40 transition-all border border-white/5 active:scale-[0.98] group"
>
<div className="w-11 h-11 rounded-full bg-linear-to-br from-primary to-primary-container flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<div className="w-11 h-11 rounded-2xl bg-linear-to-br from-primary to-primary-container flex items-center justify-center shadow-inner border border-white/10 group-hover:scale-105 transition-transform">
<Users size={20} className="text-on-primary" />
</div>
<div className="text-left">
@@ -237,6 +238,26 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
</button>
)}
{config?.import?.enableTelegramImport && mode === 'personal' && (
<button
onClick={() => {
onOpenTelegramImport?.();
onClose();
}}
className="w-full flex items-center gap-4 px-4 py-3.5 rounded-2xl bg-surface-container-highest/20 hover:bg-surface-container-highest/40 transition-all border border-white/5 active:scale-[0.98] group"
>
<div className="w-11 h-11 rounded-2xl bg-linear-to-br from-[#0088cc] to-[#00aaff] flex items-center justify-center shadow-inner border border-white/10 group-hover:scale-105 transition-transform">
<MessageSquare size={20} className="text-white" />
</div>
<div className="text-left">
<p className="text-[13px] font-black uppercase tracking-tight text-white/90">{t('importTelegram')}</p>
<p className="text-[11px] text-zinc-500 font-medium tracking-wide">
{t('importTelegramDesc')}
</p>
</div>
</button>
)}
{/* Выбранные (в режиме группы) */}
{mode === 'group-select' && selectedUsers.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">