Импорт
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user