Импорт

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
+2
View File
@@ -124,6 +124,8 @@ export interface Chat {
members: ChatMember[];
messages: Message[];
unreadCount: number;
isImporting?: boolean;
importJobId?: string | null;
pinnedMessages?: Array<{
id: string;
message: Message;
@@ -44,6 +44,21 @@ const translations = {
storageAndData: 'Хранилище и данные',
importTelegram: 'Импорт истории Telegram',
importTelegramDesc: 'Перенести сообщения и медиа из Telegram',
importSuccess: 'Импорт завершен',
importStarted: 'Импорт запущен в фоновом режиме',
importSelectArchive: 'Загрузите архив',
importSelectArchiveDesc: 'Экспортируйте чат из Telegram (HTML формат, вкл. медиа) и загрузите полученный ZIP архив.',
importLimit: 'Лимит: до 1 ГБ',
importSelectFile: 'Выбрать ZIP-архив',
importParticipants: 'Соответствие участников',
importParticipantsDesc: 'Назначьте участников из архива пользователям в Knot.',
importInArchive: 'В архиве',
importSelectContact: '-- Выберите контакт --',
importGroupName: 'Название чата',
importGroupNameHint: 'Например, Моя группа',
importStart: 'Запустить импорт',
importRestart: 'Начать заново',
importCompletedDesc: 'Мы успешно создали чат и начали перенос сообщений в фоновом режиме.',
// Chat
chat: 'Чат',
group: 'Группа',
@@ -413,6 +428,21 @@ const translations = {
storageAndData: 'Storage and Data',
importTelegram: 'Import Telegram History',
importTelegramDesc: 'Move messages and media from Telegram',
importSuccess: 'Import completed',
importStarted: 'Import started in background',
importSelectArchive: 'Upload archive',
importSelectArchiveDesc: 'Export chat from Telegram (HTML format, incl. media) and upload the resulting ZIP archive.',
importLimit: 'Limit: up to 1 GB',
importSelectFile: 'Select ZIP archive',
importParticipants: 'Participant mapping',
importParticipantsDesc: 'Assign participants from the archive to Knot users.',
importInArchive: 'In archive',
importSelectContact: '-- Select contact --',
importGroupName: 'Chat name',
importGroupNameHint: 'e.g., My Group',
importStart: 'Start import',
importRestart: 'Start over',
importCompletedDesc: 'We successfully created the chat and started importing messages in the background.',
searchChats: 'Search chats...',
chat: 'Chat',
group: 'Group',
@@ -46,7 +46,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
const gradientClass = generateAvatarColor(name || '');
return (
<div className={`relative shrink-0 ${sizeClass} ${rounding} ${className} border-2 border-outline-variant/10 overflow-hidden shadow-inner`}>
<div className={`relative shrink-0 ${sizeClass} ${rounding} ${className} overflow-hidden`}>
{src ? (
<img
src={src}
@@ -55,7 +55,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
/>
) : (
<div
className={`w-full h-full ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter shadow-inner`}
className={`w-full h-full ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter`}
>
{initials}
</div>
@@ -1,5 +1,6 @@
import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useLang } from '../../../infrastructure/i18n';
interface ConfirmModalProps {
@@ -25,49 +26,50 @@ export default function ConfirmModal({
}: ConfirmModalProps) {
const { t } = useLang();
return (
const content = (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md px-4"
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
initial={{ scale: 0.95, opacity: 0, y: 15 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
exit={{ scale: 0.95, opacity: 0, y: 15 }}
transition={{ type: 'spring', damping: 25, stiffness: 450 }}
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-[360px] mx-4 rounded-2xl bg-surface-secondary border border-border/50 shadow-2xl overflow-hidden"
className="w-full max-w-[400px] rounded-[2.5rem] bg-[#1c1c1c] border border-white/10 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.8)] overflow-hidden"
>
<div className="p-5 flex flex-col items-center text-center">
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3 ${danger ? 'bg-red-500/15' : 'bg-accent/15'}`}>
<AlertTriangle size={24} className={danger ? 'text-red-400' : 'text-accent'} />
<div className="p-8 pb-6 flex flex-col items-center text-center">
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mb-6 ${danger ? 'bg-error/15' : 'bg-primary/15'}`}>
<AlertTriangle size={32} className={danger ? 'text-error' : 'text-primary'} />
</div>
{title && (
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
<h3 className="text-white text-xl font-black mb-3 tracking-tight leading-tight">{title}</h3>
)}
<p className="text-zinc-400 text-sm leading-relaxed">{message}</p>
<p className="text-zinc-400 text-[16px] leading-relaxed font-medium px-2">
{message}
</p>
</div>
<div className="flex border-t border-border/40">
<div className="grid grid-cols-2 p-6 pt-2 gap-4">
<button
onClick={onCancel}
className="flex-1 py-3 text-sm font-medium text-zinc-400 hover:bg-surface-hover hover:text-white transition-colors"
className="py-4 px-6 rounded-2xl text-[15px] font-bold text-zinc-400 bg-white/5 hover:bg-white/10 transition-all active:scale-95"
>
{cancelText || t('cancel')}
</button>
<div className="w-px bg-border/40" />
<button
onClick={onConfirm}
className={`flex-1 py-3 text-sm font-medium transition-colors ${
className={`py-4 px-6 rounded-2xl text-[15px] font-black transition-all active:scale-95 shadow-lg ${
danger
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
: 'text-accent hover:bg-accent/10'
? 'bg-error text-on-error hover:brightness-110 shadow-error/20'
: 'bg-gradient-to-br from-primary to-primary-container text-on-primary hover:brightness-110 shadow-primary/20'
}`}
>
{confirmText || t('confirm')}
@@ -78,4 +80,6 @@ export default function ConfirmModal({
)}
</AnimatePresence>
);
return createPortal(content, document.body);
}
@@ -37,7 +37,6 @@ import { getSocket } from '../../infrastructure/socket';
import { useLang } from '../../infrastructure/i18n';
import { useThemeStore, ChatTheme } from '../../application/stores/themeStore';
import DatePicker from '../components/ui/DatePicker';
import TelegramImportModal from '../../../modules/users/presentation/components/TelegramImportModal';
import type { User as UserType, UserPresence, FriendRequest, FriendWithId } from '../../domain/types';
import { getInitials } from '../../utils/utils';
@@ -48,9 +47,10 @@ interface SideMenuProps {
isOpen: boolean;
onClose: () => void;
onOpenProfile: () => void;
onOpenTelegramImport: () => void;
}
export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuProps) {
export default function SideMenu({ isOpen, onClose, onOpenProfile, onOpenTelegramImport }: SideMenuProps) {
const { user, updateUser, logout } = useAuthStore();
const { clearStore } = useChatStore();
const { chatTheme, setChatTheme } = useThemeStore();
@@ -60,8 +60,6 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
const [prevView, setPrevView] = useState<SideView>('main');
const [themeIndex, setThemeIndex] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showImportModal, setShowImportModal] = useState(false);
// Friends state
const {
@@ -325,7 +323,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
<div className="px-5 py-3 border-t border-border mt-2">
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">Хранилище и данные</h4>
<button
onClick={() => { loadFriends(); setShowImportModal(true); }}
onClick={() => { onOpenTelegramImport(); }}
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
>
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
@@ -674,7 +672,6 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
{view === 'about' && renderAbout()}
</AnimatePresence>
</motion.div>
<TelegramImportModal isOpen={showImportModal} onClose={() => setShowImportModal(false)} friends={friends} />
</>
)}
</AnimatePresence>
@@ -12,6 +12,7 @@ import { useAuthStore } from '../../../modules/auth/application/authStore';
import { useChatStore } from '../../../modules/chats/application/chatStore';
import { useNotificationStore } from '../../application/stores/notificationStore';
import { useLang } from '../../infrastructure/i18n';
import { useFriendStore } from '../../../modules/friends/application/friendStore';
import { StoryApi } from '../../../modules/stories/infrastructure/storyApi';
import { getSocket } from '../../infrastructure/socket';
import { getInitials, generateAvatarColor } from '../../utils/utils';
@@ -24,6 +25,7 @@ import SideMenu from './SideMenu';
import StoryViewer from '../../../modules/stories/presentation/components/StoryViewer';
import { CreateStoryModal } from '../../../modules/stories/presentation/components/CreateStoryModal';
import { useStoryStore } from '../../../modules/stories/application/storyStore';
import TelegramImportModal from '../../../modules/users/presentation/components/TelegramImportModal';
const API_URL = import.meta.env.VITE_API_URL || '';
@@ -36,6 +38,8 @@ export default function Sidebar() {
const [showSideMenu, setShowSideMenu] = useState(false);
const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore();
const [showCreateStory, setShowCreateStory] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const { friends, loadFriends } = useFriendStore();
const loadStories = () => {
StoryApi.getStories()
@@ -63,14 +67,17 @@ export default function Sidebar() {
const handleOpenNewChat = () => setShowNewChat(true);
const handleOpenSideMenu = () => setShowSideMenu(true);
const handleOpenImport = () => { loadFriends(); setShowImportModal(true); };
window.addEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
window.addEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
window.addEventListener('OPEN_TELEGRAM_IMPORT', handleOpenImport);
return () => {
clearInterval(interval);
socket?.off('story_viewed', onStoryViewed);
window.removeEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
window.removeEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
window.removeEventListener('OPEN_TELEGRAM_IMPORT', handleOpenImport);
};
}, [user?.id]);
@@ -80,9 +87,9 @@ export default function Sidebar() {
if (chat.name?.toLowerCase().includes(q)) return true;
return chat.members.some(
(m) =>
m.user.id !== user?.id &&
((m.user.username || m.user.userName || '').toLowerCase().includes(q) ||
(m.user.displayName || '').toLowerCase().includes(q))
m.user?.id !== user?.id &&
((m.user?.username || m.user?.userName || '').toLowerCase().includes(q) ||
(m.user?.displayName || '').toLowerCase().includes(q))
);
}).sort((a, b) => {
// 1. Favorites chat always on top
@@ -90,13 +97,19 @@ export default function Sidebar() {
if (b.type === 'favorites') return 1;
// 2. Pinned chats next
const aPinned = a.members.find(m => m.user.id === user?.id)?.isPinned ?? false;
const bPinned = b.members.find(m => m.user.id === user?.id)?.isPinned ?? false;
const aPinned = a.members?.find(m => m.user?.id === user?.id)?.isPinned ?? false;
const bPinned = b.members?.find(m => m.user?.id === user?.id)?.isPinned ?? false;
if (aPinned && !bPinned) return -1;
if (!aPinned && bPinned) return 1;
// 3. Last message timestamp (if available) - though currently we don't have it on top level
return 0;
// 3. Importing chats next
if (a.isImporting && !b.isImporting) return -1;
if (!a.isImporting && b.isImporting) return 1;
// 4. Last message timestamp or CreatedAt
const aTime = new Date(a.messages?.[0]?.createdAt || a.createdAt).getTime();
const bTime = new Date(b.messages?.[0]?.createdAt || b.createdAt).getTime();
return bTime - aTime;
});
const handleLogout = () => {
@@ -214,7 +227,7 @@ export default function Sidebar() {
{/* Модалки */}
<AnimatePresence>
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} />}
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} onOpenTelegramImport={() => { loadFriends(); setShowImportModal(true); }} />}
</AnimatePresence>
<AnimatePresence>
{showProfile && user && <UserProfile userId={user.id} onClose={() => setShowProfile(false)} isSelf />}
@@ -223,6 +236,12 @@ export default function Sidebar() {
isOpen={showSideMenu}
onClose={() => setShowSideMenu(false)}
onOpenProfile={() => setShowProfile(true)}
onOpenTelegramImport={() => { loadFriends(); setShowImportModal(true); }}
/>
<TelegramImportModal
isOpen={showImportModal}
onClose={() => setShowImportModal(false)}
friends={friends}
/>
<AnimatePresence>
{viewerIndex !== null && storyGroups.length > 0 && (
@@ -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">
@@ -5,6 +5,7 @@ import { AppApi } from '../../../../core/infrastructure/appApi';
import { useLang } from '../../../../core/infrastructure/i18n';
import type { User as UserType, FriendWithId } from '../../../../core/domain/types';
import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../../chats/application/chatStore';
interface TelegramImportModalProps {
isOpen: boolean;
@@ -54,7 +55,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
setStep(2);
} catch (err: any) {
console.error(err);
setError(err.message || 'Ошибка загрузки файла');
setError(err.message || 'Ошибка анализа файла');
} finally {
setLoading(false);
}
@@ -67,12 +68,17 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
try {
const res = await AppApi.executeTelegramImport({ token, mapping, groupName }) as any;
setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' });
setImportedState({ count: 0, text: 'Импорт запущен в фоновом режиме' });
setStep(3);
// Обновляем список чатов и выбираем новый
await useChatStore.getState().loadChats();
if (res.chatId) {
useChatStore.getState().setActiveChat(res.chatId);
}
} catch (err: any) {
console.error(err);
setError(err.message || 'Ошибка импорта');
setStep(1);
} finally {
setLoading(false);
}
@@ -103,47 +109,56 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
className="absolute inset-0 bg-black/40 backdrop-blur-md"
onClick={step === 3 && importedState ? handleClose : undefined}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
initial={{ opacity: 0, scale: 0.9, y: 40 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative w-full max-w-lg bg-surface-secondary border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col max-h-[90vh]"
exit={{ opacity: 0, scale: 0.9, y: 40 }}
className="relative w-full max-w-[480px] bg-[#121212]/95 backdrop-blur-3xl border border-white/10 shadow-[0_32px_64px_rgba(0,0,0,0.8)] rounded-[2.5rem] overflow-hidden flex flex-col max-h-[90vh] slide-on-ice"
>
{/* Header */}
<div className="h-14 px-4 flex items-center justify-between border-b border-border bg-surface-secondary/50 backdrop-blur-md shrink-0">
<div className="flex items-center gap-2">
<MessageSquare size={20} className="text-knot-400" />
<h3 className="font-semibold text-white">Импорт из Telegram</h3>
<div className="h-14 px-8 flex items-center justify-between shrink-0 mt-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-2xl bg-linear-to-br from-[#0088cc] to-[#00aaff] flex items-center justify-center shadow-inner border border-white/10">
<MessageSquare size={18} className="text-white" />
</div>
<h3 className="text-[17px] font-black uppercase tracking-tight text-white/90">{t('importTelegram')}</h3>
</div>
<button
onClick={handleClose}
className="p-2 -mr-2 text-zinc-400 hover:text-white hover:bg-white/10 rounded-xl transition-all"
className="w-10 h-10 flex items-center justify-center text-zinc-500 hover:text-white bg-white/5 hover:bg-white/10 rounded-2xl transition-all active:scale-[0.85]"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
{error && (
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 flex gap-3 text-red-400">
<AlertCircle size={20} className="shrink-0" />
<p className="text-sm">{error}</p>
<div className="mb-6 p-5 rounded-3xl bg-error/10 border border-error/20 flex gap-4 text-error animate-in fade-in slide-in-from-top-2">
<AlertCircle size={22} className="shrink-0" />
<div className="flex flex-col gap-1">
<span className="text-[10px] font-black uppercase tracking-widest opacity-60">{t('error')}</span>
<p className="text-[13px] leading-relaxed font-medium">{error}</p>
</div>
</div>
)}
{step === 1 && (
<div className="text-center space-y-6">
<div className="w-20 h-20 mx-auto bg-surface-tertiary rounded-full flex items-center justify-center border border-border">
<Upload size={32} className="text-knot-400" />
<div className="text-center py-6 space-y-8 animate-in fade-in zoom-in-95 duration-500">
<div
onClick={() => fileInputRef.current?.click()}
className="w-24 h-24 mx-auto bg-surface-container-highest/20 rounded-[2rem] flex items-center justify-center border border-white/5 shadow-2xl cursor-pointer hover:bg-surface-container-highest/30 active:scale-95 transition-all group"
>
<Upload size={36} className="text-knot-400 group-hover:scale-110 transition-transform" />
</div>
<div>
<h4 className="text-lg font-medium text-white mb-2">Загрузите архив с историей</h4>
<p className="text-sm text-zinc-400 leading-relaxed max-w-sm mx-auto">
Скачайте историю чата из Telegram в формате HTML (сняв галочку с формата JSON). Убедитесь, что медиафайлы тоже скачаны, если хотите перенести их. Загрузите полученный ZIP архив.
</p>
<div className="space-y-3">
<h4 className="text-xl font-black text-white/90 font-headline uppercase tracking-tight">{t('importSelectArchive')}</h4>
<div className="text-[13px] text-zinc-500 leading-relaxed font-medium mx-auto">
{t('importSelectArchiveDesc')}<br/>
<span className="opacity-60 text-[11px] font-black uppercase tracking-widest mt-2 block">{t('importLimit')}</span>
</div>
</div>
<input
@@ -157,43 +172,46 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
<button
onClick={() => fileInputRef.current?.click()}
disabled={loading}
className="h-12 px-6 bg-knot-500 hover:bg-knot-600 active:bg-knot-700 text-white font-medium rounded-xl transition-colors inline-flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mx-auto"
className="knot-button-primary w-full h-14 rounded-2xl flex items-center justify-center gap-3 disabled:opacity-50 disabled:scale-100 transition-all font-black uppercase text-[12px] tracking-widest"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
<Loader2 size={20} className="animate-spin" />
) : (
<>
<Upload size={18} />
Выбрать ZIP-архив
{t('importSelectFile')}
</>
)}
</button>
</div>
)}
)}
{step === 2 && (
<div className="space-y-6">
<div>
<h4 className="text-lg font-medium text-white mb-2">Кто есть кто?</h4>
<p className="text-sm text-zinc-400">
Мы нашли {names.length} имён в архиве. Укажите, какому контакту в Knot они соответствуют. Одно из имён должно принадлежать вам.
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="space-y-2">
<h4 className="text-xl font-black text-white/90 font-headline uppercase tracking-tight">{t('importParticipants')}</h4>
<p className="text-[13px] text-zinc-500 font-medium">
{t('importParticipantsDesc')}
</p>
</div>
<div className="space-y-4">
<div className="space-y-3">
{names.map((name) => (
<div key={name} className="flex flex-col gap-2 p-4 rounded-xl border border-border bg-surface-tertiary">
<span className="text-sm font-medium text-white">Сообщения от: "{name}"</span>
<div key={name} className="flex flex-col gap-2 p-5 rounded-3xl border border-white/5 bg-surface-container-highest/10 hover:bg-surface-container-highest/20 transition-all">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500">{t('importInArchive')}</span>
<span className="text-sm font-black text-white/90">{name}</span>
</div>
<select
value={mapping[name] || ''}
onChange={(e) => setMapping({ ...mapping, [name]: e.target.value })}
className="w-full h-11 px-3 bg-surface-secondary text-sm text-white rounded-lg border border-border focus:border-knot-500 outline-none transition-colors"
className="w-full h-12 px-4 bg-black/40 text-sm text-white/90 rounded-2xl border border-white/5 focus:border-knot-400 outline-none transition-all font-medium appearance-none select-glass"
>
<option value="">-- Выберите пользователя --</option>
<option value={user?.id}>Это я ({user?.displayName || user?.username})</option>
{friends.map(f => (
<option value="">{t('importSelectContact')}</option>
<option value={user?.id}>{user?.displayName || user?.username} ({t('you')})</option>
{friends.map(f => (
<option key={f.id} value={f.id}>
Контакт: {f.displayName || f.username}
{f.displayName || f.username}
</option>
))}
</select>
@@ -201,55 +219,58 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
))}
</div>
{names.length > 2 && (
<div className="pt-2">
<h4 className="text-sm font-medium text-white mb-2">Название для группового чата</h4>
{names.length > 2 && (
<div className="space-y-3 pt-2">
<div className="flex items-center gap-2 mb-1 px-1">
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500">{t('importGroupName')}</span>
</div>
<input
type="text"
placeholder="Например, Моя группа"
placeholder={t('importGroupNameHint')}
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="w-full h-11 px-3 bg-surface-secondary text-sm text-white rounded-lg border border-border focus:border-knot-500 outline-none transition-colors placeholder:text-zinc-600"
onChange={(e) => setGroupName(e.target.value)}
className="w-full h-14 px-5 bg-black/40 text-sm text-white/90 rounded-2xl border border-white/5 focus:border-knot-400 outline-none transition-all placeholder:text-zinc-600 font-medium"
/>
</div>
)}
<div className="pt-4 flex items-center justify-end gap-3">
<button
onClick={reset}
disabled={loading}
className="px-5 py-2.5 text-sm font-medium text-zinc-400 hover:text-white transition-colors"
>
Отмена
</button>
<div className="flex flex-col gap-3">
<button
onClick={handleExecute}
disabled={loading || names.some(n => !mapping[n]) || (names.length > 2 && !groupName.trim())}
className="px-6 py-2.5 bg-knot-500 hover:bg-knot-600 disabled:bg-surface-tertiary disabled:text-zinc-500 text-white text-sm font-medium rounded-xl transition-colors flex items-center gap-2"
className="knot-button-primary w-full h-14 rounded-2xl flex items-center justify-center gap-3 disabled:opacity-30 disabled:scale-100 transition-all font-black uppercase text-[12px] tracking-widest"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
Импортировать
{loading ? <Loader2 size={20} className="animate-spin" /> : <Check size={20} />}
{t('importStart')}
</button>
<button
onClick={reset}
disabled={loading}
className="w-full h-14 flex items-center justify-center text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white transition-all"
>
{t('importRestart')}
</button>
</div>
</div>
)}
{step === 3 && importedState && (
<div className="text-center py-8 space-y-4">
<div className="w-16 h-16 mx-auto bg-green-500/20 text-green-400 rounded-full flex items-center justify-center border border-green-500/30">
<Check size={32} />
</div>
<div>
<h4 className="text-xl font-medium text-white mb-2">Готово!</h4>
<p className="text-sm text-zinc-400">
Импорт завершен. Сообщений: <strong className="text-white">{importedState.count}</strong>.
</p>
<div className="text-center py-12 space-y-10 animate-in fade-in zoom-in-95 duration-500">
<div className="w-24 h-24 mx-auto bg-green-500/10 text-green-400 rounded-[2rem] flex items-center justify-center border border-green-500/20 shadow-[0_0_40px_rgba(34,197,94,0.1)]">
<Check size={48} />
</div>
<div className="space-y-4">
<h4 className="text-2xl font-black text-white/90 font-headline uppercase tracking-tight">{t('importSuccess')}</h4>
<div className="text-sm text-zinc-500 font-medium leading-relaxed">
{t('importCompletedDesc')}<br/>
<span className="text-knot-400 font-black mt-2 block">{t('importStarted')}</span>
</div>
</div>
<button
onClick={handleClose}
className="mt-6 h-11 px-6 bg-surface-tertiary hover:bg-surface-hover active:bg-surface-secondary text-white font-medium rounded-xl transition-colors"
className="knot-button-primary w-full h-14 rounded-2xl font-black uppercase text-[12px] tracking-widest transition-all"
>
Закрыть
{t('backToChats')}
</button>
</div>
)}