Reorganize web folder structurally

This commit is contained in:
Халимов Рустам
2026-03-19 22:24:21 +03:00
parent 11ebbc853b
commit 4384233aa5
62 changed files with 0 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
import { create } from 'zustand';
import { AuthApi } from '../infrastructure/authApi';
import { connectSocket, disconnectSocket } from '../../../core/infrastructure/socket';
import type { User } from '../../../core/domain/types';
interface AuthState {
token: string | null;
user: User | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (username: string, displayName: string, password: string, bio?: string) => Promise<void>;
logout: () => void;
checkAuth: () => Promise<void>;
updateUser: (data: Partial<User>) => void;
config: any;
fetchConfig: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
token: localStorage.getItem('knot_token'),
user: null,
isLoading: true,
error: null,
config: null,
fetchConfig: async () => {
if (!get().token) return;
try {
const res = await AuthApi.getConfig();
set({ config: res });
} catch {}
},
login: async (username, password) => {
try {
set({ error: null, isLoading: true });
const { token, user } = await AuthApi.login(username, password);
localStorage.setItem('knot_token', token);
AuthApi.setToken(token);
connectSocket(token);
set({ token, user, isLoading: false });
await get().fetchConfig();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
set({ error: msg, isLoading: false });
throw err;
}
},
register: async (username, displayName, password, bio) => {
try {
set({ error: null, isLoading: true });
const { token, user } = await AuthApi.register(username, displayName, password, bio);
localStorage.setItem('knot_token', token);
AuthApi.setToken(token);
connectSocket(token);
set({ token, user, isLoading: false });
await get().fetchConfig();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
set({ error: msg, isLoading: false });
throw err;
}
},
logout: () => {
localStorage.removeItem('knot_token');
AuthApi.setToken(null);
disconnectSocket();
set({ token: null, user: null });
},
checkAuth: async () => {
const token = get().token;
if (!token) {
set({ isLoading: false });
return;
}
// Retry up to 3 times in case server is still starting
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
try {
AuthApi.setToken(token);
const { user } = await AuthApi.getMe();
connectSocket(token);
set({ user, isLoading: false });
await get().fetchConfig();
return;
} catch (err) {
lastError = err;
// Only retry on network/server errors, not on auth errors (401/403)
const msg = err instanceof Error ? err.message : '';
if (msg.includes('Требуется авторизация') || msg.includes('Недействительный токен')) {
break;
}
if (attempt < 2) {
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
}
console.warn('checkAuth failed:', lastError);
localStorage.removeItem('knot_token');
set({ token: null, user: null, isLoading: false });
},
updateUser: (data) => {
const { user } = get();
if (user) {
set({ user: { ...user, ...data } });
}
},
}));

View File

@@ -0,0 +1,11 @@
export interface LoginCredentials {
username: string;
password: string;
}
export interface RegisterCredentials {
username: string;
displayName: string;
password: string;
bio?: string;
}

View File

@@ -0,0 +1,30 @@
import { httpClient } from '../../../core/infrastructure/httpClient';
import type { User } from '../../../core/domain/types';
export class AuthApi {
static async login(username: string, password: string) {
return httpClient.request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}
static async register(username: string, displayName: string, password: string, bio?: string) {
return httpClient.request<{ token: string; user: User }>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, displayName, password, bio }),
});
}
static async getMe() {
return httpClient.request<{ user: User }>('/auth/me');
}
static async getConfig() {
return httpClient.request<any>('/config');
}
static setToken(token: string | null) {
httpClient.setToken(token);
}
}

View File

@@ -0,0 +1,89 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLang } from '../../../core/infrastructure/i18n';
import { MessageSquare } from 'lucide-react';
import LoginForm from './components/LoginForm';
import RegisterForm from './components/RegisterForm';
import { AuthApi } from '../infrastructure/authApi';
export default function AuthPage() {
const [isLogin, setIsLogin] = useState(true);
const { lang, setLang } = useLang();
const [enableRegistration, setEnableRegistration] = useState(true);
useEffect(() => {
AuthApi.getConfig()
.then(data => {
if (data && typeof data.enableRegistration === 'boolean') {
setEnableRegistration(data.enableRegistration);
if (!data.enableRegistration) setIsLogin(true);
}
})
.catch(() => {});
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col items-center justify-center relative overflow-hidden bg-[#0a0a0c]"
>
{/* Переключатель языка сверху по центру */}
<div className="absolute top-8 left-1/2 -translate-x-1/2 flex gap-4 text-sm font-semibold text-zinc-500 z-50">
<button onClick={() => setLang('en')} className={lang === 'en' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>EN</button>
<div className="w-px h-4 bg-white/10 self-center" />
<button onClick={() => setLang('ru')} className={lang === 'ru' ? 'text-[#9b66ff]' : 'hover:text-white transition-colors'}>RU</button>
</div>
{/* Карточка авторизации */}
<motion.div
initial={{ scale: 0.95, y: 20 }}
animate={{ scale: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="relative z-10 w-full max-w-[420px] mx-4"
>
<div className="bg-[#111113] rounded-[32px] p-10 shadow-2xl border border-white/5">
{/* Заголовок */}
<div className="flex flex-col items-center mb-10">
<motion.div
initial={{ rotate: -180, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
className="w-[84px] h-[84px] rounded-[28px] bg-[#1a1625] flex items-center justify-center mb-6 shadow-inner border border-white/5"
>
<MessageSquare className="w-9 h-9 text-[#8b5cf6]" />
</motion.div>
<h1 className="text-[28px] font-bold bg-gradient-to-r from-[#9b66ff] to-[#bd99ff] text-transparent bg-clip-text tracking-tight">Knot Messenger</h1>
<p className="text-zinc-500 text-[11px] mt-2.5 tracking-widest uppercase font-semibold">
{isLogin ? (lang === 'ru' ? 'вход' : 'login') : (lang === 'ru' ? 'регистрация' : 'registration')}
</p>
</div>
<AnimatePresence mode="wait">
<motion.div
key={isLogin ? 'login' : 'register'}
initial={{ opacity: 0, x: isLogin ? -20 : 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isLogin ? 20 : -20 }}
transition={{ duration: 0.2 }}
>
{isLogin ? (
<LoginForm
enableRegistration={enableRegistration}
onRegisterClick={() => setIsLogin(false)}
/>
) : (
<RegisterForm
enableRegistration={enableRegistration}
onLoginClick={() => setIsLogin(true)}
/>
)}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,127 @@
import React, { useState, FormEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Eye, EyeOff, ArrowRight } from 'lucide-react';
import { useAuthStore } from '../../application/authStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import type { LoginCredentials } from '../../domain/types';
interface Props {
onRegisterClick?: () => void;
enableRegistration?: boolean;
}
export default function LoginForm({ onRegisterClick, enableRegistration }: Props) {
const [credentials, setCredentials] = useState<LoginCredentials>({ username: '', password: '' });
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuthStore();
const { lang } = useLang();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
await login(credentials.username, credentials.password);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка');
setIsSubmitting(false);
}
};
return (
<>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
role="alert"
>
{error}
</motion.div>
)}
</AnimatePresence>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
Username
</label>
<input
type="text"
value={credentials.username}
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="username"
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
required
autoFocus
autoComplete="off"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Пароль' : 'Password'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
disabled={isSubmitting}
type="submit"
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
style={{ marginTop: '32px' }}
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
{lang === 'ru' ? 'Войти' : 'Login'}
<ArrowRight size={18} />
</>
)}
</motion.button>
</form>
{enableRegistration && onRegisterClick && (
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
<p className="text-zinc-500 text-[13px] font-medium">
{lang === 'ru' ? 'Нет аккаунта?' : "Don't have an account?"}
</p>
<button
onClick={onRegisterClick}
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
type="button"
>
{lang === 'ru' ? 'Зарегистрироваться' : 'Register'}
</button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,160 @@
import React, { useState, FormEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Eye, EyeOff, ArrowRight } from 'lucide-react';
import { useAuthStore } from '../../application/authStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import type { RegisterCredentials } from '../../domain/types';
interface Props {
onLoginClick: () => void;
enableRegistration?: boolean;
}
export default function RegisterForm({ onLoginClick, enableRegistration }: Props) {
const [credentials, setCredentials] = useState<RegisterCredentials>({ username: '', displayName: '', password: '', bio: '' });
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { register } = useAuthStore();
const { lang } = useLang();
if (!enableRegistration) return null;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
await register(credentials.username, credentials.displayName || credentials.username, credentials.password, credentials.bio);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка');
setIsSubmitting(false);
}
};
return (
<>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium text-center"
role="alert"
>
{error}
</motion.div>
)}
</AnimatePresence>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
Username <span className="text-zinc-600 font-normal ml-1">({lang === 'ru' ? 'латиница, нельзя изменить' : 'latin, cannot change'})</span>
</label>
<input
type="text"
value={credentials.username}
onChange={(e) => setCredentials({ ...credentials, username: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="username"
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
required
autoFocus
autoComplete="off"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Отображаемое имя' : 'Display Name'}
</label>
<input
type="text"
value={credentials.displayName}
onChange={(e) => setCredentials({ ...credentials, displayName: e.target.value })}
placeholder={lang === 'ru' ? 'Ваше имя (любой язык)' : 'Your name'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'Пароль' : 'Password'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={credentials.password}
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
placeholder={lang === 'ru' ? 'Введите пароль' : 'Enter password'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors pr-12 outline-none text-[15px]"
required
autoComplete="new-password"
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<p className="mt-2 text-[12px] text-zinc-500 flex items-center gap-1.5 font-medium">
<span className="w-1 h-1 rounded-full bg-[#9b66ff]" />
{lang === 'ru' ? 'Минимум 8 символов, буквы и цифры' : 'Minimum 8 characters, letters and numbers'}
</p>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-300 mb-2">
{lang === 'ru' ? 'О себе' : 'About me'}
</label>
<input
type="text"
value={credentials.bio}
onChange={(e) => setCredentials({ ...credentials, bio: e.target.value })}
placeholder={lang === 'ru' ? 'Расскажите о себе (необязательно)' : 'Tell about yourself (optional)'}
className="w-full px-4 py-3.5 rounded-xl bg-[#18181b] border border-white/5 text-white placeholder-zinc-600 focus:border-[#9b66ff]/50 focus:bg-[#1f1f22] transition-colors outline-none text-[15px]"
/>
</div>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
disabled={isSubmitting}
type="submit"
className="w-full py-3.5 px-4 rounded-xl bg-[#8b5cf6] hover:bg-[#7c3aed] text-white font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-8 transition-colors text-[16px]"
style={{ marginTop: '32px' }}
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
{lang === 'ru' ? 'Создать аккаунт' : 'Create account'}
<ArrowRight size={18} />
</>
)}
</motion.button>
</form>
{enableRegistration && (
<div className="mt-8 pt-6 border-t border-white/5 flex justify-center items-center gap-2">
<p className="text-zinc-500 text-[13px] font-medium">
{lang === 'ru' ? 'Уже есть аккаунт?' : 'Already have an account?'}
</p>
<button
onClick={onLoginClick}
className="text-[#7c3aed] hover:text-[#8b5cf6] text-[13px] font-semibold transition-colors"
type="button"
>
{lang === 'ru' ? 'Войти' : 'Login'}
</button>
</div>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,549 @@
import { create } from 'zustand';
import { ChatApi } from '../infrastructure/chatApi';
import { useAuthStore } from '../../auth/application/authStore';
import type { Chat, ChatMember, Message, TypingUser } from '../../../core/domain/types';
interface ChatState {
chats: Chat[];
activeChat: string | null;
messages: Record<string, Message[]>;
pinnedMessages: Record<string, Message>;
typingUsers: TypingUser[];
replyTo: Message | null;
editingMessage: Message | null;
isLoadingChats: boolean;
isLoadingMessages: boolean;
searchQuery: string;
drafts: Record<string, string>;
hasMoreMessages: Record<string, boolean>;
setActiveChat: (chatId: string | null) => void;
setSearchQuery: (query: string) => void;
setDraft: (chatId: string, text: string) => void;
getDraft: (chatId: string) => string;
loadChats: () => Promise<void>;
loadMessages: (chatId: string, reset?: boolean, isHistory?: boolean) => Promise<void>;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (messageId: string, chatId: string) => void;
removeMessages: (messageIds: string[], chatId: string) => void;
hideMessages: (messageIds: string[], chatId: string) => void;
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
markRead: (chatId: string, userId: string, messageIds: string[]) => void;
markAllAsRead: (chatId: string) => void;
addTypingUser: (chatId: string, userId: string) => void;
removeTypingUser: (chatId: string, userId: string) => void;
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
setReplyTo: (message: Message | null) => void;
setEditingMessage: (message: Message | null) => void;
addChat: (chat: Chat) => void;
updateChat: (chat: Chat) => void;
removeChat: (chatId: string) => void;
clearMessages: (chatId: string) => void;
setPinnedMessage: (chatId: string, message: Message) => void;
removePinnedMessage: (chatId: string, messageId: string, newPinned: Message | null) => void;
clearStore: () => void;
}
export const useChatStore = create<ChatState>((set, get) => ({
chats: [],
activeChat: null,
messages: {},
pinnedMessages: {},
typingUsers: [],
replyTo: null,
editingMessage: null,
isLoadingChats: false,
isLoadingMessages: false,
searchQuery: '',
drafts: JSON.parse(localStorage.getItem('knot_drafts') || '{}'),
hasMoreMessages: {},
setActiveChat: (chatId) => set((state) => ({
activeChat: chatId,
replyTo: null,
editingMessage: null,
chats: chatId
? state.chats.map((c) => c.id === chatId ? { ...c, unreadCount: 0 } : c)
: state.chats,
})),
setSearchQuery: (query) => set({ searchQuery: query }),
setDraft: (chatId, text) => {
set((state) => {
const drafts = { ...state.drafts };
if (text.trim()) {
drafts[chatId] = text;
} else {
delete drafts[chatId];
}
localStorage.setItem('knot_drafts', JSON.stringify(drafts));
return { drafts };
});
},
getDraft: (chatId) => {
return get().drafts[chatId] || '';
},
loadChats: async () => {
try {
set({ isLoadingChats: true });
const chats = await ChatApi.getChats();
// Auto-create favorites chat if not present
if (!chats.some((c: any) => c.type === 'favorites')) {
try {
const favChat = await ChatApi.getOrCreateFavorites();
chats.unshift(favChat);
} catch { }
}
// Extract pinned messages from chats
const pinnedMessages: Record<string, Message> = {};
for (const chat of chats) {
if (chat.pinnedMessages && chat.pinnedMessages.length > 0) {
pinnedMessages[chat.id] = chat.pinnedMessages[0].message;
}
}
set({ chats, pinnedMessages, isLoadingChats: false });
} catch (error: any) {
console.error('Load chats error:', error);
set({ isLoadingChats: false });
const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState();
addNotification('error', error.message || 'Failed to load chats');
}
},
loadMessages: async (chatId, reset = false, isHistory = false) => {
try {
const state = get();
if (!reset && !isHistory && typeof state.hasMoreMessages[chatId] !== 'undefined') return;
if (!reset && isHistory && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return;
if (state.isLoadingMessages) return;
set({ isLoadingMessages: true });
const currentMessages = state.messages[chatId] || [];
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].createdAt : undefined;
const fetched = await ChatApi.getMessages(chatId, cursor);
set((state) => {
// Merge fetched messages with any that arrived via socket
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()
);
return {
messages: { ...state.messages, [chatId]: merged },
hasMoreMessages: { ...state.hasMoreMessages, [chatId]: fetched.length === 100 },
isLoadingMessages: false,
};
});
} catch (error: any) {
console.error('Load messages error:', error);
set({ isLoadingMessages: false });
const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState();
addNotification('error', error.message || 'Failed to load messages');
}
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
set((state) => {
const chatMessages = state.messages[message.chatId] || [];
if (chatMessages.some((m) => m.id === message.id)) return state;
const updatedMessages = {
...state.messages,
[message.chatId]: [...chatMessages, message],
};
const updatedChats = state.chats.map((chat) => {
if (chat.id === message.chatId) {
return {
...chat,
messages: [message],
unreadCount: (chat.id === state.activeChat || message.senderId === userId) ? chat.unreadCount : chat.unreadCount + 1,
};
}
return chat;
});
updatedChats.sort((a, b) => {
const aPin = a.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
const bPin = b.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
if (aPin !== bPin) return bPin - aPin;
const aTime = a.messages[0]?.createdAt || a.createdAt;
const bTime = b.messages[0]?.createdAt || b.createdAt;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
return { messages: updatedMessages, chats: updatedChats };
});
},
updateMessage: (message) => {
set((state) => {
const chatMessages = state.messages[message.chatId] || [];
const updatedMessages = chatMessages.map((m) => (m.id === message.id ? message : m));
const updatedChats = state.chats.map((chat) => {
if (chat.id === message.chatId) {
return {
...chat,
messages: chat.messages?.map((m) => (m.id === message.id ? message : m)),
};
}
return chat;
});
return {
messages: {
...state.messages,
[message.chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
removeMessage: (messageId, chatId) => {
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.map((m) =>
m.id === messageId ? { ...m, isDeleted: true, content: null } : m
);
// Find the latest non-deleted message to show in sidebar
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
// If the deleted message was the last message shown, replace with previous one
const currentLast = chat.messages?.[0];
if (currentLast?.id === messageId) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
};
}
}
return chat;
});
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
removeMessages: (messageIds, chatId) => {
const idsSet = new Set(messageIds);
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.map((m) =>
idsSet.has(m.id) ? { ...m, isDeleted: true, content: null } : m
);
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const currentLast = chat.messages?.[0];
if (currentLast && idsSet.has(currentLast.id)) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
};
}
}
return chat;
});
return {
messages: { ...state.messages, [chatId]: updatedMessages },
chats: updatedChats,
};
});
},
hideMessages: (messageIds, chatId) => {
const idsSet = new Set(messageIds);
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.filter((m) => !idsSet.has(m.id));
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const currentLast = chat.messages?.[0];
if (currentLast && idsSet.has(currentLast.id)) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [],
};
}
}
return chat;
});
return {
messages: { ...state.messages, [chatId]: updatedMessages },
chats: updatedChats,
};
});
},
addReaction: (messageId, chatId, userId, username, emoji) => {
console.log('[ChatStore] addReaction called:', { messageId, chatId, userId, username, emoji });
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updateMsg = (m: Message) => {
if (m.id === messageId) {
const reactions = m.reactions || [];
const exists = reactions.some((r) => r.userId === userId && r.emoji === emoji);
if (exists) {
console.log('[ChatStore] Reaction already exists, skipping');
return m;
}
console.log('[ChatStore] Adding reaction to message:', m.id);
return {
...m,
reactions: [
...reactions,
{ id: `${messageId}-${userId}-${emoji}`, emoji, userId, user: { id: userId, username, displayName: username } },
],
};
}
return m;
};
const updatedMessages = chatMessages.map(updateMsg);
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
return {
...chat,
messages: chat.messages?.map(updateMsg),
};
}
return chat;
});
console.log('[ChatStore] State updated for chatId:', chatId);
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
removeReaction: (messageId, chatId, userId, emoji) => {
console.log('[ChatStore] removeReaction called:', { messageId, chatId, userId, emoji });
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updateMsg = (m: Message) => {
if (m.id === messageId) {
console.log('[ChatStore] Removing reaction from message:', m.id);
return {
...m,
reactions: (m.reactions || []).filter((r) => !(r.userId === userId && r.emoji === emoji)),
};
}
return m;
};
const updatedMessages = chatMessages.map(updateMsg);
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
return {
...chat,
messages: chat.messages?.map(updateMsg),
};
}
return chat;
});
console.log('[ChatStore] State updated for chatId:', chatId);
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
markRead: (chatId, userId, messageIds) => {
const currentUserId = useAuthStore.getState().user?.id;
set((state) => {
const chatMessages = state.messages[chatId] || [];
let newlyReadCount = 0;
const updateMsg = (m: Message) => {
if (messageIds.includes(m.id)) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m;
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
return { ...m, readBy: [...(m.readBy || []), { userId }] };
}
return m;
};
const updatedMessages = chatMessages.map(updateMsg);
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const updatedLastMessages = chat.messages?.map(updateMsg);
if (userId === currentUserId) {
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
}
return { ...chat, messages: updatedLastMessages };
}
return chat;
});
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
markAllAsRead: (chatId) => {
set((state) => {
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
return { ...chat, unreadCount: 0 };
}
return chat;
});
return { chats: updatedChats };
});
},
addTypingUser: (chatId, userId) => {
set((state) => {
const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId);
if (exists) return state;
return { typingUsers: [...state.typingUsers, { chatId, userId }] };
});
},
removeTypingUser: (chatId, userId) => {
set((state) => ({
typingUsers: state.typingUsers.filter((t) => !(t.chatId === chatId && t.userId === userId)),
}));
},
updateUserOnlineStatus: (userId, isOnline, lastSeen) => {
set((state) => ({
chats: state.chats.map((chat) => ({
...chat,
members: chat.members.map((m) =>
m.user.id === userId
? { ...m, user: { ...m.user, isOnline, lastSeen: lastSeen || m.user.lastSeen } }
: m
),
})),
}));
},
setReplyTo: (message) => set({ replyTo: message, editingMessage: null }),
setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }),
addChat: (chat) => {
set((state) => {
const existing = state.chats.find((c) => c.id === chat.id);
const messagesFromState = state.messages[chat.id] || [];
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
let unreadCount = chat.unreadCount || 0;
if (!existing && messagesFromState.length > 0) {
const userId = useAuthStore.getState().user?.id;
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
}
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
if (existing) {
return {
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...updatedChat } : c)),
};
}
return { chats: [updatedChat, ...state.chats] };
});
},
updateChat: (chat) => {
set((state) => ({
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...chat } : c)),
}));
},
removeChat: (chatId) => {
set((state) => ({
chats: state.chats.filter((c) => c.id !== chatId),
activeChat: state.activeChat === chatId ? null : state.activeChat,
messages: (() => { const m = { ...state.messages }; delete m[chatId]; return m; })(),
}));
},
clearMessages: (chatId) => {
set((state) => ({
messages: { ...state.messages, [chatId]: [] },
chats: state.chats.map((c) =>
c.id === chatId ? { ...c, messages: [] } : c
),
}));
},
setPinnedMessage: (chatId, message) => {
set((state) => ({
pinnedMessages: { ...state.pinnedMessages, [chatId]: message },
}));
},
removePinnedMessage: (chatId, _messageId, newPinned) => {
set((state) => {
const updated = { ...state.pinnedMessages };
if (newPinned) {
updated[chatId] = newPinned;
} else {
delete updated[chatId];
}
return { pinnedMessages: updated };
});
},
clearStore: () => {
set({
chats: [],
activeChat: null,
messages: {},
pinnedMessages: {},
typingUsers: [],
replyTo: null,
editingMessage: null,
});
},
}));

View File

@@ -0,0 +1,112 @@
import { httpClient } from '../../../core/infrastructure/httpClient';
import type { Chat, Message } from '../../../core/domain/types';
export class ChatApi {
static async getChats() {
return httpClient.request<Chat[]>('/chats');
}
static async createPersonalChat(userId: string) {
return httpClient.request<Chat>('/chats/personal', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
static async createGroupChat(name: string, memberIds: string[]) {
return httpClient.request<Chat>('/chats/group', {
method: 'POST',
body: JSON.stringify({ name, memberIds }),
});
}
static async getMessages(chatId: string, cursor?: string) {
const params = cursor ? `?cursor=${cursor}` : '';
return httpClient.request<Message[]>(`/messages/chat/${chatId}${params}`);
}
static async uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
return httpClient.request<{ url: string; filename: string; size: number }>('/messages/upload', {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async updateGroup(chatId: string, data: { name?: string; description?: string }) {
return httpClient.request<Chat>(`/chats/${chatId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
static async uploadGroupAvatar(chatId: string, file: File) {
const formData = new FormData();
formData.append('avatar', file);
return httpClient.request<Chat>(`/chats/${chatId}/avatar`, {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async cropGroupAvatar(chatId: string, file: File, cropData: { x: number; y: number; width: number; height: number }) {
const formData = new FormData();
formData.append('avatar', file);
formData.append('x', cropData.x.toString());
formData.append('y', cropData.y.toString());
formData.append('width', cropData.width.toString());
formData.append('height', cropData.height.toString());
return httpClient.request<Chat>(`/chats/${chatId}/avatar/crop`, {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async removeGroupAvatar(chatId: string) {
return httpClient.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
}
static async addGroupMembers(chatId: string, userIds: string[]) {
return httpClient.request<Chat>(`/chats/${chatId}/members`, {
method: 'POST',
body: JSON.stringify({ userIds }),
});
}
static async removeGroupMember(chatId: string, userId: string) {
return httpClient.request<Chat>(`/chats/${chatId}/members/${userId}`, {
method: 'DELETE',
});
}
static async clearChat(chatId: string) {
return httpClient.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
}
static async deleteChat(chatId: string) {
return httpClient.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
}
static async togglePinChat(chatId: string) {
return httpClient.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
}
static async searchMessages(query: string, chatId?: string) {
const params = new URLSearchParams({ q: query });
if (chatId) params.append('chatId', chatId);
return httpClient.request<Message[]>(`/messages/search?${params}`);
}
static async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
return httpClient.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
}
static async getOrCreateFavorites() {
return httpClient.request<Chat>('/chats/favorites', { method: 'POST' });
}
}

View File

@@ -0,0 +1,469 @@
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useChatStore } from '../application/chatStore';
import { useAuthStore } from '../../auth/application/authStore';
import { getSocket, disconnectSocket } from '../../../core/infrastructure/socket';
import { ChatApi } from '../infrastructure/chatApi';
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../core/utils/sounds';
import { useLang } from '../../../core/infrastructure/i18n';
import type { Message, UserBasic, CallInfo } from '../../../core/domain/types';
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
import Sidebar from '../../../core/presentation/layouts/Sidebar';
import ChatView from './components/ChatView';
import CallModal from '../../calls/presentation/components/CallModal';
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
export default function ChatPage() {
const {
loadChats,
addMessage,
updateMessage,
removeMessage,
removeMessages,
hideMessages,
addReaction,
removeReaction,
markRead,
addTypingUser,
removeTypingUser,
updateUserOnlineStatus,
setPinnedMessage,
removePinnedMessage,
clearStore,
addChat,
} = useChatStore();
const { user } = useAuthStore();
const initialized = useRef(false);
// Call state
const [callOpen, setCallOpen] = useState(false);
const [callTarget, setCallTarget] = useState<UserBasic | null>(null);
const [callType, setCallType] = useState<'voice' | 'video'>('voice');
const [incomingCall, setIncomingCall] = useState<CallInfo | null>(null);
const [callSessionId, setCallSessionId] = useState(0);
const [deliveryNotification, setDeliveryNotification] = useState<string | null>(null);
const deliveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Group call state
const [groupCallOpen, setGroupCallOpen] = useState(false);
const [groupCallChatId, setGroupCallChatId] = useState('');
const [groupCallChatName, setGroupCallChatName] = useState('');
const [groupCallType, setGroupCallType] = useState<'voice' | 'video'>('voice');
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
const groupCallOpenRef = useRef(false);
const groupCallChatIdRef = useRef('');
const { t } = useLang();
useEffect(() => {
groupCallOpenRef.current = groupCallOpen;
groupCallChatIdRef.current = groupCallChatId;
}, [groupCallOpen, groupCallChatId]);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
loadChats();
}, [loadChats]);
// Обработка закрытия вкладки — отправить disconnect
useEffect(() => {
const handleBeforeUnload = () => {
const socket = getSocket();
if (socket) {
socket.disconnect();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);
useEffect(() => {
const socket = getSocket();
if (!socket) return;
socket.on('new_message', async (message: Message) => {
// If this chat isn't in our store yet (e.g. someone just created it and sent a message),
// fetch chats so the new chat appears in the sidebar immediately
const { chats } = useChatStore.getState();
if (!chats.some(c => c.id === message.chatId)) {
try {
const allChats = await ChatApi.getChats();
const newChat = allChats.find(c => c.id === message.chatId);
if (newChat) {
// Reset unreadCount to 0 because addMessage below will increment it by 1
useChatStore.getState().addChat({ ...newChat, unreadCount: 0 });
}
} catch (e) {
console.error('Failed to fetch new chat:', e);
}
}
addMessage(message);
// Play notification sound for messages from others
if (message.senderId !== user?.id && !message.storyId && !isChatMuted(message.chatId)) {
playNotificationSound();
}
});
socket.on('scheduled_delivered', async (message: Message & { _recipientName?: string; _deliveredAt?: string }) => {
// If chat unknown, fetch it first
const { chats } = useChatStore.getState();
if (!chats.some(c => c.id === message.chatId)) {
try {
const allChats = await ChatApi.getChats();
const newChat = allChats.find(c => c.id === message.chatId);
if (newChat) useChatStore.getState().addChat(newChat);
} catch (_) { /* ignore */ }
}
// A scheduled message was delivered: update it in store (remove scheduledAt)
updateMessage({ ...message, scheduledAt: null });
// Show delivery notification to the sender
if (message.senderId === user?.id && message._recipientName) {
const time = message._deliveredAt
? new Date(message._deliveredAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: '';
const notifText = `${useLang.getState().t('scheduledDelivered')} ${message._recipientName} ${useLang.getState().t('scheduledDeliveredAt')} ${time}`;
setDeliveryNotification(notifText);
if (deliveryTimerRef.current) clearTimeout(deliveryTimerRef.current);
deliveryTimerRef.current = setTimeout(() => setDeliveryNotification(null), 5000);
}
// Notify others with sound
if (message.senderId !== user?.id && !isChatMuted(message.chatId)) {
playNotificationSound();
}
});
socket.on('message_edited', (message: Message) => {
updateMessage(message);
});
socket.on('new_chat', (chat: any) => {
addChat(chat);
socket.emit('join_chat', chat.id);
});
socket.on('message_deleted', (data: { messageId: string; chatId: string }) => {
removeMessage(data.messageId, data.chatId);
});
socket.on('messages_deleted', (data: { messageIds: string[]; chatId: string }) => {
removeMessages(data.messageIds, data.chatId);
});
socket.on('messages_hidden', (data: { messageIds: string[]; chatId: string }) => {
hideMessages(data.messageIds, data.chatId);
});
socket.on('reaction_added', (data: { messageId: string; chatId: string; userId: string; username: string; emoji: string }) => {
console.log('[Socket] reaction_added received:', data);
addReaction(data.messageId, data.chatId, data.userId, data.username, data.emoji);
});
socket.on('reaction_removed', (data: { messageId: string; chatId: string; userId: string; emoji: string }) => {
console.log('[Socket] reaction_removed received:', data);
removeReaction(data.messageId, data.chatId, data.userId, data.emoji);
});
socket.on('messages_read', (data: any) => {
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.messageIds || data.MessageIds || []);
});
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
if (data.userId !== user?.id) {
addTypingUser(data.chatId, data.userId);
setTimeout(() => removeTypingUser(data.chatId, data.userId), 3000);
}
});
socket.on('user_stopped_typing', (data: { chatId: string; userId: string }) => {
removeTypingUser(data.chatId, data.userId);
});
socket.on('user_online', (data: { userId: string }) => {
updateUserOnlineStatus(data.userId, true);
});
socket.on('user_offline', (data: { userId: string; lastSeen?: string }) => {
updateUserOnlineStatus(data.userId, false, data.lastSeen);
});
socket.on('message_pinned', (data: { chatId: string; message: Message }) => {
setPinnedMessage(data.chatId, data.message);
});
socket.on('message_unpinned', (data: { chatId: string; messageId: string; newPinnedMessage: Message | null }) => {
removePinnedMessage(data.chatId, data.messageId, data.newPinnedMessage);
});
socket.on('call_incoming', async (data: CallInfo) => {
// Use callerInfo from server if available, otherwise look up from chats
let callerInfo: UserBasic | null = data.callerInfo || null;
if (!callerInfo) {
const { chats } = useChatStore.getState();
for (const chat of chats) {
const member = chat.members.find((m) => m.user.id === data.from);
if (member) {
callerInfo = member.user;
break;
}
}
}
setCallTarget(null); // Clear any previous outgoing target
setIncomingCall({
from: data.from,
offer: data.offer,
callType: data.callType,
chatId: data.chatId,
callerInfo,
});
setCallType(data.callType);
setCallSessionId(id => id + 1);
setCallOpen(true);
});
// Story events - registered globally so they work even when StoryViewer is closed
socket.on('story_viewed', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number; ownerId: string }) => {
console.log('[Socket] story_viewed received:', data);
// Only process if this user is the owner
if (data.ownerId === user?.id) {
console.log('[Socket] This is my story, updating view count');
}
});
socket.on('story_reply', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string; ownerId: string }) => {
console.log('[Socket] story_reply received:', data);
// Only process if this user is the owner
if (data.ownerId === user?.id) {
console.log('[Socket] This is my story, got reply:', data.content);
}
});
socket.on('story_reaction', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; emoji: string; createdAt: string; ownerId: string }) => {
console.log('[Socket] story_reaction received:', data);
// Only process if this user is the owner
if (data.ownerId === user?.id) {
console.log('[Socket] This is my story, got reaction:', data.emoji);
}
});
socket.on('group_call_incoming', (data: { chatId: string; from: string; callerInfo: any; callType: string }) => {
if (data.from === user?.id) return;
if (groupCallOpenRef.current && groupCallChatIdRef.current === data.chatId) return;
const { chats } = useChatStore.getState();
const chat = chats.find(c => c.id === data.chatId);
if (!chat) return;
playCallRingtone();
setIncomingGroupCall({
chatId: data.chatId,
from: data.from,
callerInfo: data.callerInfo,
callType: data.callType,
chatName: chat.name || 'Group',
});
// Auto-dismiss after 15 seconds if ignored
setTimeout(() => {
setIncomingGroupCall(prev => {
if (prev?.chatId === data.chatId) {
stopCallRingtone();
return null;
}
return prev;
});
}, 15000);
});
socket.on('group_call_ended', (data: { chatId: string }) => {
setIncomingGroupCall(prev => {
if (prev?.chatId === data.chatId) {
stopCallRingtone();
return null;
}
return prev;
});
});
return () => {
socket.off('new_message');
socket.off('scheduled_delivered');
socket.off('message_edited');
socket.off('new_chat');
socket.off('message_deleted');
socket.off('messages_deleted');
socket.off('messages_hidden');
socket.off('reaction_added');
socket.off('reaction_removed');
socket.off('messages_read');
socket.off('user_typing');
socket.off('user_stopped_typing');
socket.off('user_online');
socket.off('user_offline');
socket.off('message_pinned');
socket.off('message_unpinned');
socket.off('call_incoming');
socket.off('story_viewed');
socket.off('story_reply');
socket.off('story_reaction');
socket.off('group_call_incoming');
socket.off('group_call_ended');
};
}, [user?.id]);
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
setCallTarget(targetUser);
setCallType(type);
setIncomingCall(null);
setCallSessionId(id => id + 1);
setCallOpen(true);
};
useEffect(() => {
const handleCustomCallEvent = ((e: CustomEvent) => {
if (e.detail?.targetUser && e.detail?.type) {
handleStartCall(e.detail.targetUser, e.detail.type);
}
}) as EventListener;
window.addEventListener('START_CALL', handleCustomCallEvent);
return () => window.removeEventListener('START_CALL', handleCustomCallEvent);
}, []);
const handleStartGroupCall = (chatId: string, chatName: string, type: 'voice' | 'video') => {
setGroupCallChatId(chatId);
setGroupCallChatName(chatName);
setGroupCallType(type);
setGroupCallSessionId(id => id + 1);
setGroupCallOpen(true);
};
const handleCloseCall = () => {
setCallOpen(false);
setCallTarget(null);
setIncomingCall(null);
};
const handleCloseGroupCall = () => {
setGroupCallOpen(false);
};
const activeChat = useChatStore((state) => state.activeChat);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex bg-surface sm:p-3 sm:gap-3 overflow-hidden"
>
<div className={`${activeChat ? 'hidden sm:block' : 'block'} w-full sm:w-[340px] flex-shrink-0`}>
<Sidebar />
</div>
<div className={`${activeChat ? 'block' : 'hidden sm:block'} flex-1 h-full min-w-0`}>
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
</div>
<CallModal
key={callSessionId}
isOpen={callOpen}
onClose={handleCloseCall}
targetUser={callTarget}
callType={callType}
incoming={incomingCall}
/>
<GroupCallModal
key={`gc-${groupCallSessionId}`}
isOpen={groupCallOpen}
onClose={handleCloseGroupCall}
chatId={groupCallChatId}
chatName={groupCallChatName}
callType={groupCallType}
/>
{/* Scheduled message delivery notification */}
<AnimatePresence>
{deliveryNotification && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
className="fixed top-6 left-1/2 -translate-x-1/2 z-[9999] px-5 py-3 rounded-2xl bg-surface-secondary shadow-2xl border border-border flex items-center gap-3"
>
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<Send size={14} className="text-emerald-400" />
</div>
<span className="text-sm text-zinc-200">{deliveryNotification}</span>
</motion.div>
)}
</AnimatePresence>
{/* Incoming Group Call Overlay */}
<AnimatePresence>
{incomingGroupCall && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-zinc-900 border border-white/10 p-8 rounded-3xl w-full max-w-sm flex flex-col items-center shadow-2xl"
>
<div className="relative mb-6">
<div className="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave" />
<div className="relative w-24 h-24 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-4xl font-bold text-white uppercase overflow-hidden">
{incomingGroupCall.callerInfo?.avatar ? (
<img src={incomingGroupCall.callerInfo.avatar} className="w-full h-full object-cover" />
) : (
<>{incomingGroupCall.chatName.charAt(0)}</>
)}
</div>
</div>
<h2 className="text-2xl text-white font-semibold mb-2 text-center break-words w-full max-w-full">
{incomingGroupCall.chatName}
</h2>
<p className="text-emerald-400 font-medium mb-1 truncate w-full text-center">
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('calling' as any) || 'звонит...'}
</p>
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
{t('group' as any) || 'Групповой звонок'}
</p>
<div className="flex items-center gap-8 w-full justify-center">
<button
onClick={() => {
stopCallRingtone();
setIncomingGroupCall(null);
}}
className="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors shadow-lg shadow-red-500/20"
>
<PhoneOff size={28} />
</button>
<button
onClick={() => {
stopCallRingtone();
handleStartGroupCall(incomingGroupCall.chatId, incomingGroupCall.chatName, incomingGroupCall.callType as any);
setIncomingGroupCall(null);
}}
className="w-16 h-16 rounded-full bg-emerald-500 hover:bg-emerald-600 flex items-center justify-center text-white transition-colors animate-pulse shadow-lg shadow-emerald-500/20"
>
<Phone size={28} className="animate-wiggle" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -0,0 +1,214 @@
import { useState, useRef, useEffect, memo } from 'react';
import { formatDistanceToNow } from 'date-fns';
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 { useLang } from '../../../../core/infrastructure/i18n';
import { stripMarkdown } from '../../../../core/utils/utils';
import { ChatApi } from '../../infrastructure/chatApi';
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import type { Chat } from '../../../../core/domain/types';
interface ChatListItemProps {
chat: Chat;
isActive: boolean;
}
function ChatListItem({ chat, isActive }: ChatListItemProps) {
const { user } = useAuthStore();
const { setActiveChat, loadMessages, typingUsers, drafts, loadChats } = useChatStore();
const { t, lang } = useLang();
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const ctxRef = useRef<HTMLDivElement>(null);
const myMember = chat.members.find((m) => m.user.id === user?.id);
const isPinned = myMember?.isPinned ?? false;
const draft = drafts[chat.id] || '';
const otherMember = chat.members.find((m) => m.user.id !== user?.id);
const isFavorites = chat.type === 'favorites';
const chatName = isFavorites
? t('favorites')
: chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
: chat.name || t('group');
const chatAvatar = isFavorites
? null
: chat.type === 'personal'
? otherMember?.user.avatar
: chat.avatar;
const isOnline = chat.type === 'personal' && otherMember?.user.isOnline;
// Check if someone is typing in this chat
const typingInChat = typingUsers.filter((t) => t.chatId === chat.id && t.userId !== user?.id);
const isTyping = typingInChat.length > 0;
const lastMessage = chat.messages?.[0];
const lastMessageText = lastMessage
? lastMessage.isDeleted
? t('messageDeleted')
: lastMessage.type === 'voice'
? t('voice')
: lastMessage.type === 'file' || lastMessage.type === 'image' || lastMessage.type === 'video'
? lastMessage.media?.[0]?.type === 'image'
? t('photo')
: lastMessage.media?.[0]?.type === 'video'
? t('video')
: t('file')
: lastMessage.content || ''
: '';
const previewText = stripMarkdown(lastMessageText);
const isMine = lastMessage?.senderId === user?.id;
// Галочки прочтения
const isRead = lastMessage?.readBy?.some((r) => r.userId !== user?.id);
const timeStr = lastMessage
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
: '';
const handleClick = () => {
setActiveChat(chat.id);
loadMessages(chat.id);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
useEffect(() => {
if (!ctxMenu) return;
const close = (e: MouseEvent) => {
if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) setCtxMenu(null);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [ctxMenu]);
const handlePin = async () => {
setCtxMenu(null);
try {
await ChatApi.togglePinChat(chat.id);
loadChats();
} catch (e) { console.error(e); }
};
const handleDelete = async () => {
setCtxMenu(null);
setShowDeleteConfirm(true);
};
const confirmDelete = async () => {
setShowDeleteConfirm(false);
try {
await ChatApi.deleteChat(chat.id);
useChatStore.getState().removeChat(chat.id);
} catch (e) { console.error(e); }
};
const initials = chatName
.split(' ')
.map((w: string) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<div className="relative">
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
isActive ? 'bg-accent/15 border-r-2 border-accent' : 'hover:bg-surface-hover'
}`}
>
{/* Аватар */}
<div className="relative flex-shrink-0">
{isFavorites ? (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg">
<Bookmark size={22} className="text-white" />
</div>
) : (
<Avatar src={chatAvatar} name={chatName} size="lg" online={isOnline ? true : undefined} />
)}
</div>
{/* Инфо */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
{isPinned && <Pin size={12} className="text-knot-400 flex-shrink-0 rotate-45" />}
<span className="text-sm font-medium text-white truncate">{chatName}</span>
</div>
{timeStr && <span className="text-xs text-zinc-500 flex-shrink-0 ml-2">{timeStr}</span>}
</div>
<div className="flex items-center justify-between mt-0.5">
<div className="flex items-center gap-1 min-w-0 flex-1">
{isMine && lastMessage && !lastMessage.isDeleted && (
<span className="flex-shrink-0">
{isRead ? (
<CheckCheck size={14} className="text-knot-400" />
) : (
<Check size={14} className="text-zinc-500" />
)}
</span>
)}
<p className={`text-xs truncate ${isTyping ? 'text-knot-400 font-medium' : draft ? 'text-red-400' : 'text-zinc-400'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-medium">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
</p>
</div>
{chat.unreadCount > 0 && !isActive && (
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-accent flex items-center justify-center text-[11px] text-white font-medium">
{chat.unreadCount}
</span>
)}
</div>
</div>
</button>
{/* Context Menu */}
{ctxMenu && (
<div
ref={ctxRef}
className="fixed z-[9999] min-w-[180px] py-1 rounded-xl bg-surface-secondary border border-border shadow-xl animate-in fade-in zoom-in-95 duration-100"
style={{ top: ctxMenu.y, left: ctxMenu.x }}
>
<button
onClick={handlePin}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Pin size={16} className={isPinned ? 'rotate-45' : ''} />
{isPinned ? t('unpinChat') : t('pinChat')}
</button>
<div className="border-t border-border my-1" />
<button
onClick={handleDelete}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={16} />
{t('deleteChat')}
</button>
</div>
)}
<ConfirmModal
open={showDeleteConfirm}
message={t('deleteChatConfirm')}
onConfirm={confirmDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
</div>
);
}
export default memo(ChatListItem);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,246 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
import { Search, TrendingUp, Loader2 } from 'lucide-react';
import { useLang } from '../../../../core/infrastructure/i18n';
import { useAuthStore } from '../../../auth/application/authStore';
import { AppApi } from '../../../../core/infrastructure/appApi';
interface KlipyGif {
id: string;
files?: any;
file?: any;
media_formats?: any;
media?: any;
images?: any;
title?: string;
}
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
onSelectGif?: (url: string, preview: string) => void;
onClose: () => void;
}
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
const { lang, t } = useLang();
const { config } = useAuthStore();
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
const [gifQuery, setGifQuery] = useState('');
const [gifs, setGifs] = useState<KlipyGif[]>([]);
const [gifLoading, setGifLoading] = useState(false);
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
const gifSearchRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const initialFetchDone = useRef(false);
// Helper to safely extract GIF array from various possible Klipy API responses
const extractGifs = (d: any): KlipyGif[] => {
if (!d) return [];
if (d.data && Array.isArray(d.data.data)) return d.data.data;
if (Array.isArray(d)) return d;
if (Array.isArray(d.data)) return d.data;
if (Array.isArray(d.result)) return d.result;
if (d.result && Array.isArray(d.result.data)) return d.result.data;
if (Array.isArray(d.gifs)) return d.gifs;
return [];
};
// Load trending GIFs (Klipy)
useEffect(() => {
if (tab === 'gif' && config?.enableKlipy && !initialFetchDone.current) {
initialFetchDone.current = true;
setGifLoading(true);
AppApi.getTrendingGifs()
.then(d => {
setTrendingGifs(extractGifs(d));
setGifLoading(false);
})
.catch((e) => {
console.error('Klipy trending error:', e);
setTrendingGifs([]);
setGifLoading(false);
});
}
}, [tab, config?.enableKlipy]);
const searchGifs = useCallback((q: string) => {
if (!config?.enableKlipy || !q.trim()) {
setGifs([]);
setGifLoading(false);
return;
}
setGifLoading(true);
AppApi.searchKlipyGifs(q)
.then(d => {
setGifs(extractGifs(d));
setGifLoading(false);
})
.catch((e) => {
console.error('Klipy search error:', e);
setGifs([]);
setGifLoading(false);
});
}, [config?.enableKlipy]);
const handleGifSearch = (q: string) => {
setGifQuery(q);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => searchGifs(q), 400);
};
const getGifUrl = (gif: any): string => {
return gif.files?.hd?.gif?.url || gif.files?.sd?.gif?.url
|| gif.file?.hd?.gif?.url || gif.file?.sd?.gif?.url
|| gif.media_formats?.gif?.url || gif.media?.[0]?.gif?.url
|| gif.images?.original?.url || '';
};
const getGifPreview = (gif: any, fullUrl: string): string => {
return gif.files?.sd?.webp?.url || gif.files?.sd?.gif?.url
|| gif.file?.sd?.webp?.url || gif.file?.sd?.gif?.url
|| gif.media_formats?.tinygif?.url || gif.media?.[0]?.tinygif?.url
|| gif.images?.fixed_height_small?.url || fullUrl;
};
const pickGif = (gif: KlipyGif) => {
const url = getGifUrl(gif);
const preview = getGifPreview(gif, url);
if (onSelectGif && url) {
onSelectGif(url, preview);
}
};
const displayGifs = gifQuery.trim() ? gifs : trendingGifs;
const anchorRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useEffect(() => {
const update = () => {
const el = anchorRef.current?.parentElement;
if (!el) return;
const rect = el.getBoundingClientRect();
const w = tab === 'gif' ? 360 : 352;
let left = rect.right - w;
if (left < 8) left = 8;
setPos({ top: rect.top - 8, left });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [tab]);
const pickerWidth = tab === 'gif' ? 360 : 352;
return (
<>
<div ref={anchorRef} className="hidden" />
{createPortal(
<>
<div className="fixed inset-0 z-[9990]" onClick={onClose} />
<div
className="fixed z-[9991] rounded-xl shadow-2xl border border-border/40"
style={{
width: pickerWidth,
height: tab === 'gif' ? 435 : undefined,
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
left: pos ? pos.left : undefined,
background: '#17212b',
visibility: pos ? 'visible' : 'hidden',
}}
>
{/* Tabs */}
<div className="flex border-b border-border/40">
<button
onClick={() => setTab('emoji')}
className={`flex-1 py-3 text-[14px] font-medium transition-colors ${tab === 'emoji' ? 'text-accent border-b-[2px] border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
>
{lang === 'ru' ? 'Эмодзи' : 'Emoji'}
</button>
{config?.enableKlipy && onSelectGif && (
<button
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
className={`flex-1 py-3 text-[14px] font-medium transition-colors ${tab === 'gif' ? 'text-accent border-b-[2px] border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
>
GIF
</button>
)}
</div>
{/* Emoji tab */}
{tab === 'emoji' && (
<Picker
data={data}
onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
theme="dark"
locale={lang === 'ru' ? 'ru' : 'en'}
set="native"
previewPosition="none"
skinTonePosition="search"
perLine={9}
emojiSize={28}
emojiButtonSize={36}
maxFrequentRows={2}
navPosition="top"
dynamicWidth={false}
/>
)}
{/* GIF tab */}
{config?.enableKlipy && tab === 'gif' && (
<div className="flex flex-col h-[calc(100%-41px)]">
<div className="p-2">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
ref={gifSearchRef}
value={gifQuery}
onChange={(e) => handleGifSearch(e.target.value)}
placeholder={t('searchGifs')}
className="w-full pl-8 pr-3 py-2 rounded-lg bg-surface-tertiary/80 text-sm text-white placeholder-zinc-500 border border-border/30 focus:border-accent/50 outline-none transition-colors"
/>
</div>
</div>
{!gifQuery.trim() && !gifLoading && (
<div className="flex items-center gap-1.5 px-3 pb-1">
<TrendingUp size={12} className="text-zinc-500" />
<span className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold">{t('trending')}</span>
</div>
)}
<div className="flex-1 overflow-y-auto p-1.5">
{gifLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={24} className="text-zinc-500 animate-spin" />
</div>
) : displayGifs.length === 0 ? (
<p className="text-center text-xs text-zinc-500 py-10">{gifQuery ? t('nothingFound') : ''}</p>
) : (
<div className="columns-4 gap-1.5">
{displayGifs.map((gif) => (
<button
key={gif.id}
onClick={() => { pickGif(gif); onClose(); }}
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
>
<img
src={getGifPreview(gif, getGifUrl(gif))}
alt={gif.title || 'GIF'}
className="w-full h-auto rounded-lg"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
</>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Search } from 'lucide-react';
import { useChatStore } from '../../application/chatStore';
import { useAuthStore } from '../../../auth/application/authStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
interface ForwardModalProps {
onClose: () => void;
onForward: (chatId: string) => void;
}
export default function ForwardModal({ onClose, onForward }: ForwardModalProps) {
const { chats } = useChatStore();
const { user } = useAuthStore();
const { t } = useLang();
const [search, setSearch] = useState('');
const filteredChats = chats
.filter((chat) => {
const otherMember = chat.members.find((m) => m.userId !== user?.id);
const chatName = chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('favorites')
: chat.name || t('group');
const finalName = chat.type === 'favorites' ? t('favorites') : chatName;
return finalName.toLowerCase().includes(search.toLowerCase());
})
.sort((a, b) => {
if (a.type === 'favorites') return -1;
if (b.type === 'favorites') return 1;
return 0;
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
role="dialog"
aria-modal="true"
aria-label={t('forward')}
className="relative w-full max-w-md bg-surface-secondary/90 glass-strong rounded-3xl overflow-hidden shadow-2xl border border-border"
>
<div className="p-4 flex items-center justify-between border-b border-white/5">
<h2 className="text-lg font-semibold text-white">{t('forwardMessage')}</h2>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
>
<X size={20} className="text-zinc-400" />
</button>
</div>
<div className="p-4">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
<input
type="text"
placeholder={t('searchChats') || 'Поиск чатов'}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-black/20 border border-white/10 rounded-xl py-2.5 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-knot-500 transition-colors"
/>
</div>
<div className="max-h-80 overflow-y-auto space-y-1 pr-2 custom-scrollbar">
{filteredChats.map((chat) => {
const otherMember = chat.members.find((m) => m.userId !== user?.id);
const chatName =
chat.type === 'favorites' ? t('favorites') :
chat.type === 'personal'
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
: chat.name || t('group');
const chatAvatar = chat.type === 'personal'
? otherMember?.user.avatar
: chat.avatar;
return (
<button
key={chat.id}
onClick={() => onForward(chat.id)}
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors text-left"
>
<Avatar src={chatAvatar} name={chatName} size="md" />
<span className="text-white font-medium flex-1 truncate">{chatName}</span>
</button>
);
})}
{filteredChats.length === 0 && (
<p className="text-center text-zinc-500 py-4 text-sm">{t('nothingFound')}</p>
)}
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,911 @@
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
X,
Camera,
Edit3,
Check,
Loader2,
UserPlus,
Trash2,
Search,
Crown,
Users,
ImageIcon,
FileText,
Link as LinkIcon,
Play,
Download,
ExternalLink,
Video
} from 'lucide-react';
import Cropper from 'react-easy-crop';
import { ChatApi } from '../../infrastructure/chatApi';
import { UserApi } from '../../../users/infrastructure/userApi';
import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import { Chat, UserPresence, Message } from '../../../../core/domain/types';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import { getMediaUrl } from '../../../../core/utils/utils';
import { getCroppedImg } from '../../../../core/infrastructure/imageCrop';
interface GroupSettingsProps {
chat: Chat;
onClose: () => void;
onGoToMessage?: (messageId: string) => void;
}
export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSettingsProps) {
const { user } = useAuthStore();
const { updateChat } = useChatStore();
const { t, lang } = useLang();
const currentMember = chat.members.find((m) => m.user.id === user?.id);
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
const isAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(currentMember?.role || '');
const [isEditingName, setIsEditingName] = useState(false);
const [isEditingDesc, setIsEditingDesc] = useState(false);
const [groupName, setGroupName] = useState(chat.name || '');
const [groupDesc, setGroupDesc] = useState(chat.description || '');
const [isSaving, setIsSaving] = useState(false);
const [avatarUploading, setAvatarUploading] = useState(false);
const [showAddMember, setShowAddMember] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [activeTab, setActiveTab] = useState<'members' | 'gifs' | 'media' | 'files' | 'links'>('members');
const [tabLoading, setTabLoading] = useState(false);
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
const [sharedGifs, setSharedGifs] = useState<Message[]>([]);
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set());
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// Cropping states
const [isCropping, setIsCropping] = useState(false);
const [cropImage, setCropImage] = useState<string | null>(null);
const [cropFile, setCropFile] = useState<File | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Keep local state in sync with chat prop
useEffect(() => {
setGroupName(chat.name || '');
setGroupDesc(chat.description || '');
}, [chat.name, chat.description]);
// Search users to add
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
const timer = setTimeout(async () => {
try {
setIsSearching(true);
const results = await UserApi.searchUsers(searchQuery);
// Filter out users already in the group
const memberIds = new Set(chat.members.map((m) => m.user.id));
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
} catch (e) {
console.error(e);
} finally {
setIsSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, chat.members]);
const handleSaveName = async () => {
if (!groupName.trim()) return;
try {
setIsSaving(true);
const updatedChat = await ChatApi.updateGroup(chat.id, { name: groupName.trim() });
updateChat(updatedChat);
setIsEditingName(false);
} catch (e) {
console.error(e);
} finally {
setIsSaving(false);
}
};
const handleSaveDesc = async () => {
try {
setIsSaving(true);
const updatedChat = await ChatApi.updateGroup(chat.id, { description: groupDesc.trim() });
updateChat(updatedChat);
setIsEditingDesc(false);
} catch (e) {
console.error(e);
} finally {
setIsSaving(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
setCropImage(reader.result as string);
setCropFile(file);
setIsCropping(true);
};
reader.readAsDataURL(file);
}
};
const handleCropSave = async () => {
if (!cropImage || !croppedAreaPixels) return;
setAvatarUploading(true);
try {
const croppedFile = await getCroppedImg(cropImage, croppedAreaPixels);
if (!croppedFile) throw new Error("Could not crop image");
const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, croppedFile);
useChatStore.getState().updateChat({ ...chat, avatar: updatedChat.avatar });
setIsCropping(false);
setCropImage(null);
setCropFile(null);
} catch (err) {
console.error('Failed to crop group avatar:', err);
alert(t('error'));
} finally {
setAvatarUploading(false);
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setAvatarUploading(true);
const updatedChat = await ChatApi.uploadGroupAvatar(chat.id, file);
updateChat(updatedChat);
} catch (e) {
console.error(e);
} finally {
setAvatarUploading(false);
e.target.value = '';
}
};
const handleRemoveAvatar = async () => {
try {
setAvatarUploading(true);
const updatedChat = await ChatApi.removeGroupAvatar(chat.id);
updateChat(updatedChat);
} catch (e) {
console.error(e);
} finally {
setAvatarUploading(false);
}
};
const handleAddMember = async (userId: string) => {
try {
const updatedChat = await ChatApi.addGroupMembers(chat.id, [userId]);
updateChat(updatedChat);
setSearchQuery('');
setSearchResults([]);
} catch (e) {
console.error(e);
}
};
const handleRemoveMember = async (userId: string) => {
setRemoveTargetId(userId);
};
const confirmRemoveMember = async () => {
if (!removeTargetId) return;
try {
const updatedChat = await ChatApi.removeGroupMember(chat.id, removeTargetId);
updateChat(updatedChat);
} catch (e) {
console.error(e);
}
setRemoveTargetId(null);
};
const initials = (chat.name || 'G')
.split(' ')
.map((w: string) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
const loadTabData = async (tab: 'gifs' | 'media' | 'files' | 'links') => {
if (loadedTabs.has(tab)) return;
setTabLoading(true);
try {
const data = await ChatApi.getSharedMedia(chat.id, tab);
if (tab === 'media') setSharedMedia(data);
else if (tab === 'gifs') setSharedGifs(data);
else if (tab === 'files') setSharedFiles(data);
else setSharedLinks(data);
setLoadedTabs(prev => new Set(prev).add(tab));
} catch (e) {
console.error('Failed to load shared', tab, e);
} finally {
setTabLoading(false);
}
};
useEffect(() => {
loadTabData('gifs');
loadTabData('media');
loadTabData('files');
loadTabData('links');
}, [chat.id]);
const API_URL = import.meta.env.VITE_API_URL || '';
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
...m,
url: getMediaUrl(m.url),
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
messageId: msg.id,
createdAt: msg.createdAt
})));
const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({
...m,
url: getMediaUrl(m.url),
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
messageId: msg.id,
createdAt: msg.createdAt
})));
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedGifs = [...allGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const renderGrouped = <T extends { createdAt: string }>(
sortedItems: T[],
renderItem: (item: T, originalIndex: number) => React.ReactNode,
gridClass?: string
) => {
let currentGroup: { dateStr: string; items: {item: T, idx: number}[] } | null = null;
const groups: { dateStr: string; items: {item: T, idx: number}[] }[] = [];
sortedItems.forEach((item, idx) => {
const date = new Date(item.createdAt);
const dateStr = date.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' });
if (currentGroup?.dateStr !== dateStr) {
currentGroup = { dateStr, items: [] };
groups.push(currentGroup);
}
currentGroup.items.push({item, idx});
});
return (
<div className="flex flex-col gap-4 pb-4 px-1">
{groups.map((g, i) => (
<div key={i}>
<div className="sticky top-0 z-10 bg-black/60 backdrop-blur-md px-3 py-1.5 mb-1.5 shadow-sm border-y border-white/5">
<span className="text-[10px] font-bold text-knot-300 uppercase tracking-widest">{g.dateStr}</span>
</div>
<div className={gridClass || "flex flex-col gap-0.5"}>
{g.items.map(({item, idx}) => renderItem(item, idx))}
</div>
</div>
))}
</div>
);
};
const tabsConfig = [
{ key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length },
{ key: 'gifs' as const, label: t('gifs') || 'GIF', icon: Play, count: sortedGifs.length },
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
];
const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0);
useEffect(() => {
if (loadedTabs.size === 4 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
setActiveTab(availableTabs[0].key);
}
}, [loadedTabs, activeTab]); // availableTabs removed from dependencies to avoid infinite loops since its reference runs on every render
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-50"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, x: 50, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 50, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed right-3 top-3 bottom-3 w-[650px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border/40">
<h2 className="text-lg font-semibold text-white">{t('groupSettings')}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-xl text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
>
<X size={18} />
</button>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
{/* 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">
{chat.avatar ? (
<img
src={getMediaUrl(chat.avatar)}
alt=""
className="w-32 h-32 rounded-full object-cover shadow-inner"
/>
) : (
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-4xl shadow-inner">
{initials}
</div>
)}
</div>
{isAdmin && (
<>
<button
onClick={() => fileInputRef.current?.click()}
disabled={avatarUploading}
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
>
{avatarUploading ? (
<Loader2 size={32} className="text-white animate-spin" />
) : (
<Camera size={32} className="text-white" />
)}
</button>
{chat.avatar && !avatarUploading && (
<button
onClick={async (e) => {
e.stopPropagation();
try {
const updatedChat = await ChatApi.removeGroupAvatar(chat.id);
updateChat(updatedChat);
} catch (e) {
console.error('Failed to remove avatar', e);
}
}}
className="absolute bottom-0 right-0 p-2 rounded-full bg-red-500/90 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
>
<Trash2 size={16} />
</button>
)}
</>
)}
</div>
<div className="mt-4 flex flex-col items-center gap-2">
{isEditingName ? (
<div className="flex items-center gap-2">
<input
type="text"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="bg-surface-tertiary border border-accent/30 rounded-xl px-4 py-2 text-lg font-bold text-white text-center focus:outline-none focus:border-accent"
autoFocus
/>
<button
onClick={handleSaveName}
disabled={isSaving}
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
>
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
</button>
<button
onClick={() => { setIsEditingName(false); setGroupName(chat.name || ''); }}
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
) : (
<div
className="group/name flex items-center gap-2 cursor-pointer"
onClick={() => isAdmin && setIsEditingName(true)}
>
<h3 className="text-2xl font-bold text-white tracking-tight">
{chat.name || t('group')}
</h3>
{isAdmin && (
<Edit3 size={16} className="text-knot-400 opacity-0 group-hover/name:opacity-100 transition-opacity" />
)}
</div>
)}
<p className="text-zinc-500 text-sm">
{chat.members.length} {t('members')}
</p>
</div>
{/* Description */}
<div className="mt-6 w-full space-y-2">
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest px-1">
{t('groupDescription')}
</label>
{isEditingDesc ? (
<div className="flex items-center gap-2">
<textarea
value={groupDesc}
onChange={(e) => setGroupDesc(e.target.value)}
className="flex-1 bg-surface-tertiary border border-accent/30 rounded-xl px-3 py-2 text-sm text-white focus:outline-none min-h-[80px]"
autoFocus
/>
<div className="flex flex-col gap-2">
<button
onClick={handleSaveDesc}
disabled={isSaving}
className="p-2 rounded-lg bg-accent text-white hover:bg-accent-light transition-colors"
>
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
</button>
<button
onClick={() => { setIsEditingDesc(false); setGroupDesc(chat.description || ''); }}
className="p-2 rounded-lg bg-surface-tertiary text-zinc-400 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
</div>
) : (
<div
onClick={() => isAdmin && setIsEditingDesc(true)}
className={`group/desc relative p-3 rounded-xl border border-white/5 bg-white/5 transition-all ${isAdmin ? 'cursor-pointer hover:bg-white/10 hover:border-white/10' : ''}`}
>
<p className={`text-sm ${groupDesc ? 'text-zinc-300' : 'text-zinc-600 italic'}`}>
{groupDesc || t('noDescription')}
</p>
{isAdmin && (
<div className="absolute top-3 right-3 opacity-0 group-hover/desc:opacity-100 transition-opacity">
<Edit3 size={14} className="text-knot-400" />
</div>
)}
</div>
)}
</div>
</div>
{/* Media / Files / Links Tabs */}
{availableTabs.length > 0 ? (
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl flex flex-col flex-1 min-h-0">
<div className="flex border-b border-white/5">
{availableTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-bold uppercase tracking-widest transition-all ${
activeTab === tab.key
? 'bg-white/5 text-knot-400'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
<div className="flex items-center gap-1.5 mb-0.5">
<tab.icon size={16} />
{(loadedTabs.has(tab.key) || tab.key === 'members') && <span className="text-xs bg-black/40 px-1.5 rounded-full">{tab.count}</span>}
</div>
<span className="truncate w-full px-1">{tab.label as string}</span>
</button>
))}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{tabLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={24} className="text-zinc-500 animate-spin" />
</div>
) : activeTab === 'members' ? (
<div className="px-4 py-4 pt-2">
<div className="flex items-center justify-between mb-3">
<h4 className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
{t('membersCount')}
</h4>
{isAdmin && (
<button
onClick={() => {
setShowAddMember(!showAddMember);
if (!showAddMember) {
setTimeout(() => searchInputRef.current?.focus(), 100);
}
}}
className="flex items-center gap-1 text-xs text-knot-400 hover:text-knot-300 transition-colors"
>
<UserPlus size={14} />
{t('addMember')}
</button>
)}
</div>
{/* Add member search */}
<AnimatePresence>
{showAddMember && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden mb-3"
>
<div className="relative mb-2">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('findUser')}
className="w-full pl-8 pr-3 py-2 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
/>
</div>
{isSearching && (
<div className="flex justify-center py-2">
<Loader2 size={16} className="text-zinc-500 animate-spin" />
</div>
)}
{searchResults.map((u) => (
<button
key={u.id}
onClick={() => handleAddMember(u.id)}
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
>
{u.avatar ? (
<img src={getMediaUrl(u.avatar)} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
{(u.displayName || u.username || '?')[0].toUpperCase()}
</div>
)}
<div className="flex-1 text-left min-w-0">
<p className="text-sm text-white truncate">{u.displayName || u.username}</p>
<p className="text-xs text-zinc-500">@{u.username}</p>
</div>
<UserPlus size={14} className="text-knot-400 flex-shrink-0" />
</button>
))}
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
)}
</motion.div>
)}
</AnimatePresence>
{/* Member list */}
<div className="space-y-1">
{[...chat.members]
.sort((a, b) => {
if (a.user.id === user?.id) return -1;
if (b.user.id === user?.id) return 1;
const aIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(a.role || '');
const bIsAdmin = ['admin', 'owner', 'Admin', 'Owner'].includes(b.role || '');
if (aIsAdmin && !bIsAdmin) return -1;
if (bIsAdmin && !aIsAdmin) return 1;
return 0;
})
.map((member) => (
<div
key={member.user.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-surface-hover/50 transition-colors group"
>
<div className="relative flex-shrink-0">
{member.user.avatar ? (
<img src={getMediaUrl(member.user.avatar)} alt="" className="w-9 h-9 rounded-full object-cover" />
) : (
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
</div>
)}
{member.user.isOnline && (
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white truncate">
{member.user.displayName || member.user.username}
{member.user.id === user?.id && (
<span className="text-zinc-500 ml-1 text-xs">({t('you') || 'вы'})</span>
)}
</p>
{['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-400 text-[10px] font-medium flex-shrink-0">
<Crown size={10} />
{t('adminBadge') || 'Админ'}
</span>
)}
</div>
<p className="text-xs text-zinc-500">@{member.user.username}</p>
</div>
{isAdmin && member.user.id !== user?.id && !['admin', 'owner', 'Admin', 'Owner'].includes(member.role || '') && (
<button
onClick={() => handleRemoveMember(member.user.id)}
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
title={t('removeMember')}
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
</div>
) : activeTab === 'gifs' ? (
sortedGifs.length > 0 ? (
renderGrouped(sortedGifs, (m, idx) => (
<div
key={m.id}
onClick={() => {
const eContext = { stopPropagation: () => {} } as any;
onGoToMessage?.(m.messageId);
}}
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"
/>
<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"
>
{t('showInChat')}
</button>
</div>
), "grid grid-cols-3 gap-0.5 px-1")
) : (
<div className="flex items-center justify-center py-10 px-4 text-center">
<p className="text-xs text-zinc-500 italic">Нет GIF файлов</p>
</div>
)
) : activeTab === 'media' ? (
sortedMedia.length > 0 ? (
renderGrouped(sortedMedia, (m, idx) => (
<div
key={m.id}
onClick={() => setLightboxIndex(idx)}
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
>
{m.type === 'video' ? (
<>
<div
className="w-full h-full bg-zinc-800 flex items-center justify-center relative group-hover:scale-105 transition-transform duration-200"
onClick={() => setLightboxIndex(idx)}
>
{m.thumbnail ? (
<img src={getMediaUrl(m.thumbnail)} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
<Video size={32} className="text-white/20" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
<Play size={24} className="text-white fill-white" />
</div>
</div>
</>
) : (
<img
src={getMediaUrl(m.url)}
alt=""
onClick={() => setLightboxIndex(idx)}
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"
>
{t('showInChat')}
</button>
</div>
), "grid grid-cols-3 gap-0.5 px-1")
) : (
<div className="flex items-center justify-center py-10 px-4 text-center">
<p className="text-xs text-zinc-500 italic">{t('sharedPhotos')}</p>
</div>
)
) : activeTab === 'files' ? (
sortedFiles.length > 0 ? (
renderGrouped(sortedFiles, (msg, idx) => (
<div key={msg.id} className="divide-y divide-border border-b border-border">
{(msg.media || []).map((m) => (
<div key={m.id} className="relative group/file">
<a
href={getMediaUrl(m.url)}
download={m.filename || 'file'}
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
>
<div className="w-8 h-8 rounded-lg bg-knot-500/10 flex items-center justify-center flex-shrink-0 text-knot-400">
<FileText size={16} />
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] text-zinc-200 truncate">{m.filename || 'File'}</p>
<p className="text-[10px] text-zinc-500">{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}</p>
</div>
<Download size={14} className="text-zinc-600" />
</a>
<button
onClick={() => onGoToMessage?.(msg.id)}
className="absolute right-10 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-300 text-[11px] font-medium opacity-0 group-hover/file:opacity-100 transition-opacity"
>
{t('showInChat')}
</button>
</div>
))}
</div>
))
) : (
<div className="flex items-center justify-center py-10 px-4 text-center">
<p className="text-xs text-zinc-500 italic">{t('sharedFiles')}</p>
</div>
)
) : (
sortedLinks.length > 0 ? (
renderGrouped(sortedLinks, (msg, idx) => (
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors border-b border-white/5 relative group">
{msg.links?.map((link, i) => (
<a
key={i}
href={link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-[13px] text-knot-400 hover:underline truncate mb-1"
>
<ExternalLink size={12} className="flex-shrink-0" />
{link}
</a>
))}
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
<button
onClick={() => onGoToMessage?.(msg.id)}
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-black/40 hover:bg-knot-500/20 text-zinc-300 hover:text-white text-[11px] font-medium opacity-0 group-hover:opacity-100 transition-all shadow-md z-10"
>
{t('showInChat')}
</button>
</div>
))
) : (
<div className="flex items-center justify-center py-10 px-4 text-center">
<p className="text-xs text-zinc-500 italic">{t('sharedLinks')}</p>
</div>
)
)}
</div>
</div>
) : loadedTabs.size === 4 ? (
<div className="mx-4 mb-6 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
<ImageIcon size={32} className="text-zinc-600 mb-3" />
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
</div>
) : (
<div className="mx-4 mb-6 flex items-center justify-center py-10 border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
<Loader2 size={24} className="text-zinc-500 animate-spin" />
</div>
)}
</div>
</motion.div>
<ConfirmModal
open={!!removeTargetId}
message={t('confirmRemoveMember')}
onConfirm={confirmRemoveMember}
onCancel={() => setRemoveTargetId(null)}
/>
<AnimatePresence>
{lightboxIndex !== null && (
<ImageLightbox
images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</AnimatePresence>
<AnimatePresence>
{isCropping && cropImage && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center p-6"
>
<div className="w-full max-w-[400px] bg-surface-secondary rounded-[2rem] border border-white/10 overflow-hidden shadow-2xl">
<div className="p-6 border-b border-white/5 flex items-center justify-between">
<h3 className="text-xl font-bold text-white">{t('changePhoto')}</h3>
<button onClick={() => setIsCropping(false)} className="text-zinc-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
<div className="relative w-full h-80 bg-black">
<Cropper
image={cropImage}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)}
/>
</div>
<div className="p-6">
<div className="flex items-center gap-4 mb-6">
<span className="text-zinc-500 text-xs font-medium uppercase">Zoom</span>
<input
type="range"
value={zoom}
min={1}
max={3}
step={0.1}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 accent-knot-500"
/>
</div>
<div className="flex gap-3 w-full">
<button
onClick={() => setIsCropping(false)}
className="flex-1 py-3 px-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
>
{t('cancel')}
</button>
<button
onClick={handleCropSave}
disabled={avatarUploading}
className="flex-1 py-3 px-4 rounded-xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
>
{avatarUploading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
{t('save')}
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</>
);
}

View File

@@ -0,0 +1,112 @@
import { useState, useEffect } from 'react';
interface LinkPreviewProps {
url: string;
}
interface MicrolinkData {
publisher?: string;
title?: string;
description?: string;
image?: { url: string };
logo?: { url: string };
}
export default function LinkPreview({ url }: LinkPreviewProps) {
const [data, setData] = useState<MicrolinkData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let isMounted = true;
setLoading(true);
// Check cache first to avoid rate limiting
const cacheKey = `link_preview_${url}`;
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (isMounted) {
setData(parsed);
setLoading(false);
}
return;
} catch (e) {}
}
const fetchWithRetry = async (targetUrl: string, attempts = 2) => {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(`https://api.microlink.io?url=${encodeURIComponent(targetUrl)}`);
if (res.ok) {
return await res.json();
}
} catch (error) {
if (i === attempts - 1) throw error;
}
}
throw new Error('Max retries reached');
};
fetchWithRetry(url)
.then((res) => {
if (isMounted && res.status === 'success' && res.data) {
setData(res.data);
sessionStorage.setItem(cacheKey, JSON.stringify(res.data));
}
if (isMounted) setLoading(false);
})
.catch(() => {
// Suppress errors and stop after max attempts
if (isMounted) setLoading(false);
});
return () => {
isMounted = false;
};
}, [url]);
if (loading) {
return (
<div className="mt-2 text-xs text-knot-400 opacity-70 italic border-l-[3px] border-knot-500/50 pl-2">
Загрузка предпросмотра...
</div>
);
}
if (!data || (!data.title && !data.description && !data.image)) {
return null;
}
// Domain for publisher fallback
let domain = data.publisher;
if (!domain) {
try {
domain = new URL(url).hostname.replace(/^www\./, '');
} catch (e) {}
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="block mt-2 border-l-[3px] border-knot-500 bg-black/20 rounded-r-lg overflow-hidden hover:bg-black/30 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<div className="p-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-xs font-semibold text-knot-400">
{data.logo?.url && <img src={data.logo.url} alt="" className="w-3.5 h-3.5 rounded-sm object-cover" />}
<span className="truncate">{domain}</span>
</div>
{data.title && <div className="text-sm font-bold text-white leading-tight break-words">{data.title}</div>}
{data.description && <div className="text-[13px] text-zinc-300 line-clamp-3 leading-snug">{data.description}</div>}
</div>
{data.image?.url && (
<div className="w-full relative overflow-hidden bg-black/20" style={{ maxHeight: '300px' }}>
<img src={data.image.url} alt="" className="w-full h-full object-cover" />
</div>
)}
</a>
);
}

View File

@@ -0,0 +1,994 @@
import { useState, useRef, useEffect, memo } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Check,
CheckCheck,
Play,
Pause,
Download,
FileText,
Copy,
Pencil,
Trash2,
Reply,
Smile,
MoreHorizontal,
X,
Volume2,
Pin,
Clock,
Forward,
} from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore';
import { getSocket } from '../../../../core/infrastructure/socket';
import { useLang } from '../../../../core/infrastructure/i18n';
import { extractWaveform, getMediaUrl } from '../../../../core/utils/utils';
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import LinkPreview from './LinkPreview';
interface MessageBubbleProps {
message: Message;
isMine: boolean;
showAvatar: boolean;
onViewProfile?: (userId: string) => void;
selectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: (id: string) => void;
onStartSelectionMode?: (id: string) => void;
onForward?: (id: string) => void;
}
function MessageBubble({
message,
isMine,
showAvatar,
onViewProfile,
selectionMode,
isSelected,
onToggleSelect,
onStartSelectionMode,
onForward
}: MessageBubbleProps) {
const { user } = useAuthStore();
const { setReplyTo, setEditingMessage, pinnedMessages, chats } = useChatStore();
const { t, lang } = useLang();
const [showContext, setShowContext] = useState(false);
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
const [lightboxData, setLightboxData] = useState<{ index: number } | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [audioProgress, setAudioProgress] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [waveformBars, setWaveformBars] = useState<number[] | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const [quotedText, setQuotedText] = useState<string | null>(null);
// Прочитано
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (selectionMode) {
onToggleSelect?.(message.id);
return;
}
const rect = bubbleRef.current?.getBoundingClientRect();
if (!rect) return;
const selection = window.getSelection();
const text = selection?.toString().trim();
if (text && bubbleRef.current?.contains(selection?.anchorNode || null)) {
setQuotedText(text);
} else {
setQuotedText(null);
}
const menuWidth = 208;
const menuHeight = 350;
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
setContextPos({ x, y });
setShowContext(true);
};
const handleCopy = () => {
if (message.content) {
navigator.clipboard.writeText(message.content);
}
setShowContext(false);
};
const handleReply = () => {
setReplyTo({ ...message, quote: quotedText });
setShowContext(false);
setQuotedText(null);
};
const handleEdit = () => {
setEditingMessage(message);
setShowContext(false);
};
const handleDeleteForAll = () => {
const socket = getSocket();
if (socket) {
socket.emit('delete_messages', {
messageIds: [message.id],
chatId: message.chatId,
deleteForAll: true,
});
}
setShowContext(false);
setDeleteMenuMode(false);
};
const handleDeleteForMe = () => {
const socket = getSocket();
if (socket) {
socket.emit('delete_messages', {
messageIds: [message.id],
chatId: message.chatId,
deleteForAll: false,
});
}
useChatStore.getState().hideMessages([message.id], message.chatId);
setShowContext(false);
setDeleteMenuMode(false);
};
const chatForDelete = chats.find(c => c.id === message.chatId);
const otherMemberName = chatForDelete?.type === 'personal'
? chatForDelete.members.find(m => m.user.id !== user?.id)?.user.displayName
|| chatForDelete.members.find(m => m.user.id !== user?.id)?.user.username
|| ''
: '';
const isPinned = pinnedMessages[message.chatId]?.id === message.id;
const handlePin = () => {
const socket = getSocket();
if (socket) {
if (isPinned) {
socket.emit('unpin_message', { messageId: message.id, chatId: message.chatId });
} else {
socket.emit('pin_message', { messageId: message.id, chatId: message.chatId });
}
}
setShowContext(false);
};
const handleReaction = (emoji: string) => {
const socket = getSocket();
if (socket) {
const existingReaction = message.reactions?.find(
(r) => r.userId === user?.id && r.emoji === emoji
);
console.log('[Reaction] handleReaction:', {
emoji,
messageId: message.id,
chatId: message.chatId,
existingReaction: !!existingReaction,
userId: user?.id
});
if (existingReaction) {
console.log('[Reaction] Emitting remove_reaction');
socket.emit('remove_reaction', { messageId: message.id, chatId: message.chatId, emoji });
} else {
console.log('[Reaction] Emitting add_reaction');
socket.emit('add_reaction', { messageId: message.id, chatId: message.chatId, emoji });
}
} else {
console.warn('[Reaction] Socket not available');
}
setShowContext(false);
};
const toggleAudio = () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
if (audio.readyState < 2) {
audio.load();
}
audio.play().then(() => {
setIsPlaying(true);
}).catch((err) => {
console.error('Audio play error:', err);
audio.load();
audio.play().then(() => setIsPlaying(true)).catch(console.error);
});
}
};
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onTimeUpdate = () => {
if (audio.duration) {
setAudioProgress((audio.currentTime / audio.duration) * 100);
}
};
const onLoadedMetadata = () => {
setAudioDuration(audio.duration);
};
const onEnded = () => {
setIsPlaying(false);
setAudioProgress(0);
};
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('ended', onEnded);
};
}, []);
useEffect(() => {
const voiceUrl = message.media?.find((m) => m.type === 'voice')?.url;
if (!voiceUrl) return;
extractWaveform(voiceUrl, 28).then(setWaveformBars);
}, [message.media]);
const formatDuration = (sec: number) => {
if (!sec || isNaN(sec) || !isFinite(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const contextMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showContext) return;
const hideMenu = (e: MouseEvent) => {
if (contextMenuRef.current?.contains(e.target as Node)) {
return;
}
setShowContext(false);
setDeleteMenuMode(false);
};
window.addEventListener('click', hideMenu, true);
window.addEventListener('contextmenu', hideMenu, true);
return () => {
window.removeEventListener('click', hideMenu, true);
window.removeEventListener('contextmenu', hideMenu, true);
};
}, [showContext]);
if (message.isDeleted) {
return null;
}
const media = message.media || [];
const hasImage = media.some((m) => m.type === 'image');
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio');
const hasVideo = media.some((m) => m.type === 'video');
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string }[] }> = {};
(message.reactions || []).forEach((r) => {
if (!reactionGroups[r.emoji]) {
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false, avatars: [] };
}
reactionGroups[r.emoji].count++;
const displayName = r.user?.displayName || r.user?.username || '?';
reactionGroups[r.emoji].users.push(displayName);
if (reactionGroups[r.emoji].avatars.length < 3) {
reactionGroups[r.emoji].avatars.push({
url: r.user?.avatar,
initials: displayName[0].toUpperCase()
});
}
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
});
const senderName = message.sender?.displayName || message.sender?.username || '';
const senderAvatar = message.sender?.avatar;
const firstUrlMatch = message.content?.match(/https?:\/\/[^\s]+/);
const firstUrl = firstUrlMatch ? firstUrlMatch[0] : null;
const renderFormattedText = (text: string) => {
if (!text) return text;
const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+|https?:\/\/[^\s]+)/g);
return parts.map((part, i) => {
if (part.match(/^https?:\/\/[^\s]+$/)) {
return (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-sky-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
);
}
if (part.startsWith('**') && part.endsWith('**')) return <strong key={i} className="font-bold">{part.slice(2, -2)}</strong>;
if (part.startsWith('_') && part.endsWith('_')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
if (part.startsWith('*') && part.endsWith('*')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
if (part.startsWith('~') && part.endsWith('~')) return <del key={i} className="line-through opacity-80">{part.slice(1, -1)}</del>;
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={i} className="font-mono text-[13px] bg-black/20 px-1 py-0.5 rounded-[0.35rem]">{part.slice(1, -1)}</code>;
}
if (part.startsWith('@') && part.length > 1) {
const mentionUsername = part.slice(1);
return (
<span
key={i}
className="font-semibold text-sky-300 cursor-pointer hover:underline"
onClick={(e) => {
e.stopPropagation();
const chat = chats.find(c => c.id === message.chatId);
const members = chat?.members || [];
const found = members.find((m) => m.user?.username === mentionUsername);
if (found) {
onViewProfile?.(found.user.id);
}
}}
>{part}</span>
);
}
return <span key={i}>{part}</span>;
});
};
return (
<>
<div
ref={bubbleRef}
className={`flex ${isMine ? 'justify-end' : 'justify-start'} group mb-0.5 relative transition-colors duration-200 ${selectionMode ? 'px-4 -mx-4 cursor-pointer hover:bg-white/5 rounded-xl' : ''
} ${isSelected ? 'bg-knot-500/10 hover:bg-knot-500/20' : ''}`}
onClick={() => {
if (selectionMode) onToggleSelect?.(message.id);
}}
onContextMenu={handleContextMenu}
>
{selectionMode && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border border-white/30 flex items-center justify-center transition-colors">
{isSelected && <div className="w-5 h-5 rounded-full bg-knot-500 flex items-center justify-center">
<Check size={12} className="text-white" />
</div>}
</div>
)}
{!isMine && (
<div className="w-8 flex-shrink-0 mr-2 self-end">
{showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}>
{senderAvatar ? (
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
{senderName[0]?.toUpperCase() || '?'}
</div>
)}
</button>
) : null}
</div>
)}
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
{!isMine && showAvatar && (
<button
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
onClick={() => onViewProfile?.(message.senderId)}
>
{senderName}
</button>
)}
<div
id={`msg-${message.id}`}
onContextMenu={handleContextMenu}
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer max-w-full min-w-0 rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
hasImage && !message.content && !message.forwardedFrom && !message.replyTo
? 'p-0 shadow-none border-none'
: isMine
? 'bubble-sent text-white shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-br-sm'
: 'bubble-received text-zinc-100 shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-bl-[4px]'
}`}
>
{/* Reply */}
{message.replyTo && (
<div
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] cursor-pointer transition-colors -mx-1 px-1 rounded-sm ${
isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
}`}
onClick={(e) => {
e.stopPropagation();
const el = document.getElementById(`msg-${message.replyToId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 5000);
}
}}
>
<p className={`text-[13.5px] font-semibold mb-0.5 truncate ${isMine ? 'text-white' : 'text-knot-500'}`}>
{message.replyTo.sender?.displayName || message.replyTo.sender?.username}
</p>
<div className="flex items-center gap-1.5">
{message.replyTo.isDeleted ? (
<p className="text-[13px] text-white/50 italic truncate">{t('messageDeleted')}</p>
) : (
<>
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => {
const m = message.replyTo.media[0];
const isMp4 = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
return (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0 relative">
{m.type === 'image' ? (
isMp4 ? (
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
) : (
<img src={m.url} className="w-full h-full object-cover" alt="" />
)
) : m.type === 'video' ? (
<>
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
<div className="absolute inset-0 flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
</>
) : (
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-white/50" /></div>
)}
</div>
);
})()}
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => {
const m = message.replyTo.media[0];
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF';
if (m.type === 'image') return t('photo');
if (m.type === 'video') return t('video');
if (m.type === 'voice') return t('voice');
return t('media');
})() : '')}
</p>
</>
)}
</div>
</div>
)}
{/* Story Reply Quote */}
{message.storyId && (
<div
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] transition-colors -mx-1 px-1 rounded-sm ${
isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
}`}
>
<p className={`text-[11px] font-bold uppercase tracking-wider mb-1 ${isMine ? 'text-white/80' : 'text-knot-500/80'}`}>
{t('story')}
</p>
<div className="flex items-center gap-2">
{message.storyMediaUrl && (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
{message.storyMediaType === 'video' ? (
<div className="w-full h-full relative">
<video src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/20"><Play size={10} className="text-white fill-white" /></div>
</div>
) : message.storyMediaType === 'image' ? (
<img src={getMediaUrl(message.storyMediaUrl)} className="w-full h-full object-cover" alt="" />
) : (
<div className="w-full h-full flex items-center justify-center bg-knot-500/20"><FileText size={10} className="text-knot-400" /></div>
)}
</div>
)}
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
{message.quote}
</p>
</div>
</div>
)}
{/* Рендер пересланного сообщения */}
{message.forwardedFrom && (
<div
className="mb-1.5 text-[14px] opacity-90 border-l-[3px] border-white/40 pl-2.5 py-0.5 cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
onClick={() => onViewProfile?.(message.forwardedFromId!)}
>
<div className={`font-semibold ${isMine ? 'text-white' : 'text-knot-500'}`}>
{message.forwardedFrom.displayName || message.forwardedFrom.username}
</div>
</div>
)}
{/* Изображения и Видео (Галерея) */}
{(hasImage || hasVideo) && (() => {
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video');
const isSingleGif = galleryMedia.length === 1 && (
galleryMedia[0].filename === 'gif' ||
galleryMedia[0].filename === 'gif.gif' ||
galleryMedia[0].url?.includes('klipy') ||
galleryMedia[0].url?.endsWith('.gif')
);
return (
<div className={`
${(message.content || message.forwardedFrom) ? '-mx-4' : ''}
${(message.content || message.forwardedFrom) ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
${(message.content || message.forwardedFrom) ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
${isSingleGif && !(message.content || message.forwardedFrom) ? 'max-w-[260px] rounded-[1.25rem]' : ''}
${isSingleGif && (message.content || message.forwardedFrom) ? 'max-h-[260px] mx-auto' : ''}
bg-black/20 overflow-hidden relative
`}>
<div className={`grid gap-[2px] ${galleryMedia.length >= 3
? 'grid-cols-3'
: galleryMedia.length === 2
? 'grid-cols-2'
: 'grid-cols-1'
}`}>
{galleryMedia.map((m, idx) => {
const isMp4Gif = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
return m.type === 'image' ? (
isMp4Gif ? (
<video
key={m.id}
src={m.url}
autoPlay
loop
muted
playsInline
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
onClick={() => setLightboxData({ index: idx })}
/>
) : (
<img
key={m.id}
src={m.url}
alt=""
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
onClick={() => setLightboxData({ index: idx })}
/>
)
) : (
<div
key={m.id}
className={`relative cursor-pointer group/video ${galleryMedia.length > 1 ? 'aspect-square' : ''
}`}
onClick={() => setLightboxData({ index: idx })}
>
<video
src={m.url}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors">
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
</div>
</div>
);
})}
</div>
</div>
);
})()}
{/* Голосовое */}
{hasVoice && (
<div className="flex items-center gap-3 min-w-[200px]">
<audio
ref={audioRef}
src={media.find((m) => m.type === 'voice')?.url}
preload="auto"
onError={(e) => console.error('Audio load error:', e)}
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-knot-500/20 hover:bg-knot-500/30'
} transition-colors`}
>
{isPlaying ? (
<Pause size={16} className={isMine ? 'text-white' : 'text-knot-400'} />
) : (
<Play size={16} className={`${isMine ? 'text-white' : 'text-knot-400'} ml-0.5`} />
)}
</button>
<div className="flex-1 min-w-0">
<div
className="flex items-end gap-[2px] h-6 cursor-pointer"
onClick={(e) => {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
setAudioProgress(pct * 100);
if (!isPlaying) toggleAudio();
}}
>
{(waveformBars || Array(28).fill(0.5)).map((val, i) => {
const barHeight = Math.max(10, val * 100);
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-knot-400'
: isMine ? 'bg-white/20' : 'bg-white/10'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
</span>
</div>
</div>
)}
{/* Аудио (mp3 файлы) */}
{hasAudio && (() => {
const audioMedia = media.find((m) => m.type === 'audio');
return (
<div className="min-w-[220px]">
{audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2">
<Volume2 size={14} className={isMine ? 'text-white/60' : 'text-knot-400'} />
<span className={`text-xs truncate ${isMine ? 'text-white/70' : 'text-zinc-400'}`}>{audioMedia.filename}</span>
</div>
)}
<div className="flex items-center gap-3">
<audio
ref={audioRef}
src={audioMedia?.url}
preload="auto"
onError={(e) => console.error('Audio load error:', e)}
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-knot-500/20 hover:bg-knot-500/30'
} transition-colors`}
>
{isPlaying ? (
<Pause size={16} className={isMine ? 'text-white' : 'text-knot-400'} />
) : (
<Play size={16} className={`${isMine ? 'text-white' : 'text-knot-400'} ml-0.5`} />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-[2px] h-6">
{Array.from({ length: 28 }).map((_, i) => {
const barHeight = [40, 65, 35, 80, 50, 90, 45, 70, 55, 85, 30, 75, 60, 95, 40, 80, 50, 70, 35, 90, 55, 65, 45, 85, 60, 75, 50, 40][i] || 50;
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-knot-400'
: isMine ? 'bg-white/20' : 'bg-white/10'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || 0)}
</span>
</div>
</div>
</div>
);
})()}
{/* Файлы */}
{hasFile &&
media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio')
.map((m) => (
<a
key={m.id}
href={m.url}
download={m.filename || 'file'}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-3 p-2 rounded-xl ${isMine ? 'bg-white/10 hover:bg-white/15' : 'bg-surface-tertiary hover:bg-surface-hover'
} transition-colors mb-1`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isMine ? 'bg-white/20' : 'bg-knot-500/20'
}`}>
<FileText size={20} className={isMine ? 'text-white' : 'text-knot-400'} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{m.filename || t('fileLabel')}</p>
<p className={`text-xs ${isMine ? 'text-white/50' : 'text-zinc-500'}`}>
{m.size ? `${(m.size / 1024).toFixed(1)} ${t('kb')}` : t('download')}
</p>
</div>
<Download size={16} className={isMine ? 'text-white/50' : 'text-zinc-500'} />
</a>
))}
{/* Текст */}
{message.content && (() => {
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
return (
<div className="flex items-end gap-2 text-sm w-full">
<div className="flex-1 min-w-0 w-full">
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''}`}>
{renderFormattedText(message.content)}
</p>
{firstUrl && !hasImage && !hasVideo && !hasFile && (
<div className="w-full mt-1 mb-1 relative overflow-hidden">
<LinkPreview url={firstUrl} />
</div>
)}
</div>
<span className={`text-[10.5px] flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-white/60' : 'text-zinc-500'
}`}>
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
{message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />}
{timeStr}
{isMine && !message.scheduledAt && (
isRead ? (
<CheckCheck size={14} className="text-sky-300 ml-0.5" />
) : (
<Check size={14} className="ml-0.5" />
)
)}
</span>
</div>
);
})()}
{!message.content && (hasImage || hasVideo) && (
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
<span className="text-[10px] text-white/70 bg-black/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-sm">
{timeStr}
{isMine && (
isRead ? (
<CheckCheck size={13} className="text-sky-300" />
) : (
<Check size={13} />
)
)}
</span>
</div>
)}
{/* Реакции */}
{Object.keys(reactionGroups).length > 0 && (
<div className={`flex flex-wrap gap-1 mt-1.5 ${isMine ? 'justify-end' : 'justify-start'}`}>
{Object.entries(reactionGroups).map(([emoji, data]) => (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
className={`flex items-center gap-1.5 px-2.5 py-1 ${hasImage && !message.content ? 'backdrop-blur-md bg-black/40 text-white' : (isMine ? 'glass-panel text-white border-white/10 shadow-sm' : 'bg-surface-tertiary text-zinc-200 border-white/5 shadow-sm')} rounded-full transition-colors border ${
data.isMine
? (isMine ? 'bg-white/20 border-white/30' : 'bg-knot-500/20 border-knot-500/40')
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/20')
}`}
title={data.users.join(', ')}
>
<span className="text-[17px] leading-none">{emoji}</span>
{(data.avatars && data.avatars.length > 0) ? (
<div className="flex -space-x-1.5 ml-0.5">
{data.avatars.map((av, idx) => (
av.url ? (
<img key={idx} src={av.url} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 object-cover" />
) : (
<div key={idx} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-bold">
{av.initials}
</div>
)
))}
</div>
) : (
<span className="text-[12px] font-medium opacity-80 tabular-nums">{data.count}</span>
)}
{data.count > 1 && data.avatars && data.avatars.length > 0 && (
<span className="text-[12px] font-bold opacity-80 tabular-nums ml-1.5 mr-0.5">{data.count}</span>
)}
</button>
))}
</div>
)}
</div>
</div>
{isMine && (
<div className="w-8 flex-shrink-0 ml-2 self-end">
{showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}>
{senderAvatar ? (
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
{senderName[0]?.toUpperCase() || '?'}
</div>
)}
</button>
) : null}
</div>
)}
</div>
{typeof document !== 'undefined' && createPortal(
<AnimatePresence>
{showContext && (
<motion.div
ref={contextMenuRef}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed z-[9999] w-52 rounded-[1.25rem] glass-strong shadow-2xl py-1.5 overflow-hidden border border-white/10"
style={{ left: contextPos.x, top: contextPos.y }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{deleteMenuMode ? (
<>
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
<button
onClick={() => setDeleteMenuMode(false)}
className="p-1 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
</button>
<span className="text-sm font-medium text-zinc-300">{t('delete')}</span>
</div>
<button
onClick={handleDeleteForMe}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Trash2 size={16} className="text-zinc-400" />
{t('deleteForMe')}
</button>
<button
onClick={handleDeleteForAll}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
>
<Trash2 size={16} />
{chatForDelete?.type === 'personal' && otherMemberName
? `${t('deleteAlsoFor')} ${otherMemberName}`
: t('deleteForAll')}
</button>
</>
) : (
<>
<div className="flex items-center gap-1 px-3 py-2 border-b border-border">
{['👍', '❤️', '😂', '😮', '😢', '🔥'].map((emoji) => (
<button
key={emoji}
onClick={() => handleReaction(emoji)}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-surface-hover transition-colors text-lg"
>
{emoji}
</button>
))}
</div>
<button
onClick={handleReply}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Reply size={16} />
{quotedText ? t('replyWithQuote') : t('reply')}
</button>
<button
onClick={() => {
setShowContext(false);
onStartSelectionMode?.(message.id);
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<CheckCheck size={16} />
{t('select')}
</button>
<button
onClick={() => {
setShowContext(false);
onForward?.(message.id);
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Forward size={16} />
{t('forward')}
</button>
<button
onClick={handlePin}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Pin size={16} />
{isPinned ? t('unpinMessage') : t('pinMessage')}
</button>
{message.content && (
<button
onClick={handleCopy}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Copy size={16} />
{t('copy')}
</button>
)}
{isMine && message.content && (
<button
onClick={handleEdit}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
>
<Pencil size={16} />
{t('edit')}
</button>
)}
<div className="border-t border-border my-1" />
<button
onClick={() => setDeleteMenuMode(true)}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={16} />
{t('delete')}
</button>
</>
)}
</motion.div>
)}
</AnimatePresence>,
document.body
)}
<AnimatePresence>
{lightboxData && (
<ImageLightbox
images={media.filter(m => m.type === 'image' || m.type === 'video').map(m => ({ url: m.url, type: m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4') ? 'video' : m.type }))}
initialIndex={lightboxData.index}
onClose={() => setLightboxData(null)}
/>
)}
</AnimatePresence>
</>
);
}
export default memo(MessageBubble);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,380 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react';
import { ChatApi } from '../../infrastructure/chatApi';
import { UserApi } from '../../../users/infrastructure/userApi';
import { FriendApi } from '../../../friends/infrastructure/friendApi';
import { useChatStore } from '../../application/chatStore';
import { useAuthStore } from '../../../auth/application/authStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import type { UserPresence, FriendWithId } from '../../../../core/domain/types';
interface NewChatModalProps {
onClose: () => void;
}
type Mode = 'personal' | 'group-select' | 'group-name';
export default function NewChatModal({ onClose }: NewChatModalProps) {
const { user, config } = useAuthStore();
const { t } = useLang();
const { addChat, setActiveChat, loadMessages } = useChatStore();
const [mode, setMode] = useState<Mode>('personal');
const [query, setQuery] = useState('');
const [users, setUsers] = useState<UserPresence[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<UserPresence[]>([]);
const [groupName, setGroupName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [friends, setFriends] = useState<FriendWithId[]>([]);
// Load friends on mount
useEffect(() => {
FriendApi.getFriends().then(setFriends).catch(() => {});
}, []);
useEffect(() => {
if (!query.trim() || query.trim().length < 3) {
setUsers([]);
return;
}
const timer = setTimeout(async () => {
try {
setIsLoading(true);
const results = await UserApi.searchUsers(query);
setUsers(results.filter((u) => u.id !== user?.id));
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query, user?.id]);
const handleSelectUser = async (selectedUser: UserPresence) => {
if (mode === 'personal') {
try {
const chat = await ChatApi.createPersonalChat(selectedUser.id);
addChat(chat);
import('../../../../core/infrastructure/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
setActiveChat(chat.id);
loadMessages(chat.id);
onClose();
} catch (e: unknown) {
console.error(e);
}
} else {
// Toggle selection
setSelectedUsers((prev) => {
const exists = prev.find((u) => u.id === selectedUser.id);
if (exists) return prev.filter((u) => u.id !== selectedUser.id);
return [...prev, selectedUser];
});
}
};
const handleCreateGroup = async () => {
if (!groupName.trim() || selectedUsers.length === 0) return;
setIsCreating(true);
try {
const chat = await ChatApi.createGroupChat(
groupName.trim(),
selectedUsers.map((u) => u.id)
);
addChat(chat);
import('../../../../core/infrastructure/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
setActiveChat(chat.id);
loadMessages(chat.id);
onClose();
} catch (e) {
console.error(e);
} finally {
setIsCreating(false);
}
};
const isSelected = (userId: string) => selectedUsers.some((u) => u.id === userId);
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-50"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-md rounded-2xl glass-strong shadow-2xl overflow-hidden" role="dialog" aria-modal="true" aria-label={t('newChat')}>
{/* Шапка */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
{mode !== 'personal' && (
<button
onClick={() => {
if (mode === 'group-name') setMode('group-select');
else {
setMode('personal');
setSelectedUsers([]);
}
}}
className="p-1 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
>
<ArrowLeft size={18} />
</button>
)}
<h2 className="text-lg font-semibold text-white">
{mode === 'personal'
? t('newChatTitle')
: mode === 'group-select'
? t('selectMembers')
: t('newGroup')}
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
>
<X size={18} />
</button>
</div>
{mode === 'group-name' ? (
/* Шаг 2: Назвать группу */
<div className="p-4 space-y-4">
<input
type="text"
placeholder={t('groupNamePlaceholder')}
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
autoFocus
/>
<div>
<p className="text-xs text-zinc-500 mb-2">
{t('membersCount')} ({selectedUsers.length}):
</p>
<div className="flex flex-wrap gap-2">
{selectedUsers.map((u) => (
<div
key={u.id}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-knot-500/20 border border-knot-500/30"
>
{u.avatar ? (
<img src={u.avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
) : (
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-semibold">
{(u.displayName || u.username)?.[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-white">{u.displayName || u.username}</span>
<button
onClick={() => setSelectedUsers((prev) => prev.filter((p) => p.id !== u.id))}
className="text-zinc-500 hover:text-zinc-300"
>
<X size={12} />
</button>
</div>
))}
</div>
</div>
<button
onClick={handleCreateGroup}
disabled={!groupName.trim() || isCreating}
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<Users size={16} />
{t('createGroup')}
</>
)}
</button>
</div>
) : (
<>
{/* Переключатель режима + Поиск */}
<div className="p-4 space-y-3">
{mode === 'personal' && (
<button
onClick={() => setMode('group-select')}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl bg-surface-tertiary hover:bg-surface-hover transition-colors border border-border"
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center">
<Users size={18} className="text-white" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{t('createGroup')}</p>
<p className="text-xs text-zinc-500">
{t('upTo200').replace('200', String(config?.maxGroupMembers || 500))}
</p>
</div>
</button>
)}
{/* Выбранные (в режиме группы) */}
{mode === 'group-select' && selectedUsers.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{selectedUsers.map((u) => (
<button
key={u.id}
onClick={() => setSelectedUsers((prev) => prev.filter((p) => p.id !== u.id))}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-knot-500/20 border border-knot-500/30 text-xs text-white hover:bg-knot-500/30 transition-colors"
>
{(u.displayName || u.username)}
<X size={11} />
</button>
))}
</div>
)}
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
placeholder={
mode === 'personal'
? t('findUser')
: t('addMembers')
}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
autoFocus
/>
</div>
</div>
{/* Результаты */}
<div className="max-h-72 overflow-y-auto px-2 pb-4">
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-5 h-5 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
</div>
) : query.trim().length >= 3 && users.length > 0 ? (
users.map((u) => (
<button
key={u.id}
onClick={() => handleSelectUser(u)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
isSelected(u.id)
? 'bg-knot-500/15 border border-knot-500/30'
: 'hover:bg-surface-hover border border-transparent'
}`}
>
<div className="relative flex-shrink-0">
{u.avatar ? (
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
{(u.displayName || u.username)?.[0]?.toUpperCase() || '?'}
</div>
)}
{u.isOnline && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
)}
</div>
<div className="min-w-0 text-left flex-1">
<p className="text-sm font-medium text-white truncate">
{u.displayName || u.username}
</p>
<p className="text-xs text-zinc-500 truncate">@{u.username}</p>
</div>
{mode === 'group-select' && (
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
isSelected(u.id)
? 'bg-knot-500 border-knot-500'
: 'border-zinc-600'
}`}>
{isSelected(u.id) && <Check size={12} className="text-white" />}
</div>
)}
</button>
))
) : query.trim().length >= 3 && users.length === 0 ? (
<div className="text-center py-8 text-zinc-500">
<p className="text-sm">{t('usersNotFound')}</p>
</div>
) : query.trim().length > 0 && query.trim().length < 3 ? (
<div className="text-center py-6 text-zinc-500">
<p className="text-sm">{t('minCharsHint')}</p>
</div>
) : friends.length > 0 ? (
<>
<p className="text-xs text-zinc-500 uppercase tracking-wider px-2 mb-2 font-semibold">{t('friends')}</p>
{friends.map((u) => (
<button
key={u.id}
onClick={() => handleSelectUser(u)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
isSelected(u.id)
? 'bg-knot-500/15 border border-knot-500/30'
: 'hover:bg-surface-hover border border-transparent'
}`}
>
<div className="relative flex-shrink-0">
{u.avatar ? (
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
{(u.displayName || u.username)?.[0]?.toUpperCase() || '?'}
</div>
)}
{u.isOnline && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
)}
</div>
<div className="min-w-0 text-left flex-1">
<p className="text-sm font-medium text-white truncate">
{u.displayName || u.username}
</p>
<p className="text-xs text-zinc-500 truncate">@{u.username}</p>
</div>
{mode === 'group-select' && (
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
isSelected(u.id)
? 'bg-knot-500 border-knot-500'
: 'border-zinc-600'
}`}>
{isSelected(u.id) && <Check size={12} className="text-white" />}
</div>
)}
</button>
))}
</>
) : (
<div className="flex flex-col items-center gap-2 py-8 text-zinc-500">
<MessageSquare size={32} className="opacity-30" />
<p className="text-sm">{t('enterNameOrUsername')}</p>
</div>
)}
</div>
{/* Кнопка "Далее" для группы */}
{mode === 'group-select' && selectedUsers.length > 0 && (
<div className="p-4 border-t border-border">
<button
onClick={() => setMode('group-name')}
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{t('next')}
<ArrowRight size={16} />
</button>
</div>
)}
</>
)}
</div>
</motion.div>
</>
);
}

View File

@@ -0,0 +1,25 @@
import { motion } from 'framer-motion';
import { useLang } from '../../../../core/infrastructure/i18n';
export default function TypingIndicator() {
const { t } = useLang();
return (
<div className="flex items-center gap-1 py-1">
<span className="text-xs text-knot-400 font-medium">{t('typingText')}</span>
<div className="flex gap-0.5">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-1 h-1 rounded-full bg-knot-400"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{
duration: 1,
repeat: Infinity,
delay: i * 0.2,
}}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { create } from 'zustand';
import { UserApi } from '../../users/infrastructure/userApi';
import { FriendApi } from '../infrastructure/friendApi';
import type { FriendWithId, FriendRequest, UserPresence } from '../../../core/domain/types';
import { getSocket } from '../../../core/infrastructure/socket';
interface FriendState {
friends: FriendWithId[];
friendRequests: FriendRequest[];
isLoading: boolean;
searchQuery: string;
searchResults: UserPresence[];
isSearching: boolean;
setSearchQuery: (query: string) => void;
loadFriends: () => Promise<void>;
acceptRequest: (requestId: string) => Promise<void>;
declineRequest: (requestId: string) => Promise<void>;
removeFriend: (friendshipId: string) => Promise<void>;
sendRequest: (friendId: string) => Promise<void>;
searchFriends: (query: string, currentUserId?: string) => Promise<void>;
clearSearch: () => void;
initializeSocketEvents: () => () => void;
}
export const useFriendStore = create<FriendState>((set, get) => ({
friends: [],
friendRequests: [],
isLoading: false,
searchQuery: '',
searchResults: [],
isSearching: false,
setSearchQuery: (query) => set({ searchQuery: query }),
loadFriends: async () => {
set({ isLoading: true });
try {
const [friendsList, requests] = await Promise.all([
FriendApi.getFriends(),
FriendApi.getFriendRequests(),
]);
set({ friends: friendsList, friendRequests: requests });
} catch (e) {
console.error('Load friends error:', e);
} finally {
set({ isLoading: false });
}
},
acceptRequest: async (requestId) => {
try {
await FriendApi.acceptFriendRequest(requestId);
const req = get().friendRequests.find(r => r.id === requestId);
if (req) {
const socket = getSocket();
if (socket) socket.emit('friend_accepted', { friendId: req.user.id });
}
await get().loadFriends();
} catch (e) {
console.error(e);
}
},
declineRequest: async (requestId) => {
try {
await FriendApi.declineFriendRequest(requestId);
set((state) => ({
friendRequests: state.friendRequests.filter(r => r.id !== requestId)
}));
} catch (e) {
console.error(e);
}
},
removeFriend: async (friendshipId) => {
try {
const friend = get().friends.find(f => f.friendshipId === friendshipId);
await FriendApi.removeFriend(friendshipId);
if (friend) {
const socket = getSocket();
if (socket) socket.emit('friend_removed', { friendId: friend.id });
}
set((state) => ({
friends: state.friends.filter(f => f.friendshipId !== friendshipId)
}));
} catch (e) {
console.error(e);
}
},
sendRequest: async (friendId) => {
try {
const result = await FriendApi.sendFriendRequest(friendId);
const socket = getSocket();
if (socket) socket.emit('friend_request', { friendId });
if (result.status === 'accepted') {
await get().loadFriends();
}
set((state) => ({
searchResults: state.searchResults.filter(u => u.id !== friendId)
}));
} catch (e) {
console.error(e);
}
},
searchFriends: async (query, currentUserId) => {
const raw = query.trim();
const q = raw.startsWith('@') ? raw.slice(1) : raw;
if (q.length < 3) {
set({ searchResults: [] });
return;
}
set({ isSearching: true });
try {
const results = await UserApi.searchUsers(q);
const { friends } = get();
const friendIds = new Set(friends.map(f => f.id));
set({
searchResults: results.filter(u => u.id !== currentUserId && !friendIds.has(u.id))
});
} catch (e) {
console.error(e);
} finally {
set({ isSearching: false });
}
},
clearSearch: () => set({ searchQuery: '', searchResults: [] }),
initializeSocketEvents: () => {
const socket = getSocket();
if (!socket) return () => {};
const onFriendRequestReceived = () => {
FriendApi.getFriendRequests()
.then(reqs => set({ friendRequests: reqs }))
.catch(() => {});
};
const onFriendRequestAccepted = () => {
get().loadFriends();
};
const onFriendRemoved = (data: { userId: string }) => {
set((state) => ({
friends: state.friends.filter(f => f.id !== data.userId)
}));
};
socket.on('friend_request_received', onFriendRequestReceived);
socket.on('friend_request_accepted', onFriendRequestAccepted);
socket.on('friend_removed', onFriendRemoved);
return () => {
socket.off('friend_request_received', onFriendRequestReceived);
socket.off('friend_request_accepted', onFriendRequestAccepted);
socket.off('friend_removed', onFriendRemoved);
};
}
}));

View File

@@ -0,0 +1,39 @@
import { httpClient } from '../../../core/infrastructure/httpClient';
import type { FriendshipStatus, FriendRequest, FriendWithId } from '../../../core/domain/types';
export class FriendApi {
static async getFriends() {
return httpClient.request<FriendWithId[]>('/friends');
}
static async getFriendRequests() {
return httpClient.request<FriendRequest[]>('/friends/requests');
}
static async getOutgoingRequests() {
return httpClient.request<FriendRequest[]>('/friends/outgoing');
}
static async getFriendshipStatus(userId: string) {
return httpClient.request<FriendshipStatus>(`/friends/status/${userId}`);
}
static async sendFriendRequest(friendId: string) {
return httpClient.request<{ status: string }>('/friends/request', {
method: 'POST',
body: JSON.stringify({ friendId }),
});
}
static async acceptFriendRequest(friendshipId: string) {
return httpClient.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
}
static async declineFriendRequest(friendshipId: string) {
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
}
static async removeFriend(friendshipId: string) {
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
}
}

View File

@@ -0,0 +1,24 @@
import { create } from 'zustand';
import type { StoryGroup } from '../../../core/domain/types';
interface StoryState {
storyGroups: StoryGroup[];
viewerIndex: number | null;
viewerStoryIndex: number;
setStoryGroups: (groups: StoryGroup[]) => void;
openViewer: (userIndex: number, storyIndex?: number, groups?: StoryGroup[]) => void;
closeViewer: () => void;
}
export const useStoryStore = create<StoryState>((set, get) => ({
storyGroups: [],
viewerIndex: null,
viewerStoryIndex: 0,
setStoryGroups: (storyGroups) => set({ storyGroups }),
openViewer: (userIndex, storyIndex = 0, groups) => set({
storyGroups: groups || get().storyGroups,
viewerIndex: userIndex,
viewerStoryIndex: storyIndex
}),
closeViewer: () => set({ viewerIndex: null, viewerStoryIndex: 0 }),
}));

View File

@@ -0,0 +1,66 @@
import { httpClient } from '../../../core/infrastructure/httpClient';
import type { StoryGroup } from '../../../core/domain/types';
export class StoryApi {
static async getStories() {
return httpClient.request<StoryGroup[]>('/stories');
}
static async getUserStories(userId: string) {
return httpClient.request<StoryGroup>(`/stories/user/${userId}`);
}
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
return httpClient.request<{ id: string }>('/stories', {
method: 'POST',
body: JSON.stringify(data),
});
}
static async uploadVideoToStory(file: File) {
const formData = new FormData();
formData.append('file', file);
return httpClient.request<{ url: string }>('/stories/video', {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async viewStory(storyId: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
}
static async deleteStory(storyId: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
}
static async getStoryViewers(storyId: string) {
return httpClient.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
}
static async addStoryReaction(storyId: string, emoji: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
method: 'POST',
body: JSON.stringify({ emoji }),
});
}
static async removeStoryReaction(storyId: string, emoji: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}/reaction`, {
method: 'DELETE',
body: JSON.stringify({ emoji }),
});
}
static async addStoryReply(storyId: string, content: string) {
return httpClient.request<{ message: string }>(`/stories/${storyId}/reply`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
static async getStoryReplies(storyId: string) {
return httpClient.request<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
}
}

View File

@@ -0,0 +1,796 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp, Volume2, VolumeX, MessageCircle, Smile } from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore';
import { StoryApi } from '../../infrastructure/storyApi';
import { ChatApi } from '../../../chats/infrastructure/chatApi';
import { getSocket } from '../../../../core/infrastructure/socket';
import { useLang } from '../../../../core/infrastructure/i18n';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
import { StoryGroup } from '../../../../core/domain/types';
import { getMediaUrl } from '../../../../core/utils/utils';
const API_URL = import.meta.env.VITE_API_URL || '';
const STORY_BG_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#ef4444',
'#f97316', '#eab308', '#22c55e', '#14b8a6', '#0ea5e9',
'#3b82f6', '#1e1e2e',
];
const STORY_EMOJIS = ['❤️', '🔥', '😂', '😮', '😢', '👏', '🎉', '💪'];
interface StoryViewerProps {
stories: StoryGroup[];
initialUserIndex: number;
initialStoryIndex?: number;
onClose: () => void;
onRefresh: () => void;
}
export default function StoryViewer({ stories, initialUserIndex, initialStoryIndex, onClose, onRefresh }: StoryViewerProps) {
const { user } = useAuthStore();
const { t } = useLang();
const [userIndex, setUserIndex] = useState(initialUserIndex);
const [storyIndex, setStoryIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [paused, setPaused] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const viewedRef = useRef<Set<string>>(new Set());
const [viewOverrides, setViewOverrides] = useState<Record<string, { viewCount: number; viewed: boolean }>>({});
const videoRef = useRef<HTMLVideoElement>(null);
const [isMuted, setIsMuted] = useState(true);
const STORY_DURATION = 5000;
const TICK = 50;
const [showViewers, setShowViewers] = useState(false);
const [viewers, setViewers] = useState<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>([]);
const [viewersLoading, setViewersLoading] = useState(false);
const [showReplyInput, setShowReplyInput] = useState(false);
const [replyText, setReplyText] = useState('');
const [sendingReply, setSendingReply] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const [localReactions, setLocalReactions] = useState<Record<string, Array<{ id: string; userId: string; emoji: string; createdAt: string }>>>({});
const currentUser = stories[userIndex];
const rawStory = currentUser?.stories?.[storyIndex];
const currentStory = rawStory ? {
...rawStory,
...viewOverrides[rawStory.id],
reactions: localReactions[rawStory.id] || rawStory.reactions || []
} : null;
// Calculate isVideo before using it in effects
const isVideo = currentStory?.type === 'video' || (currentStory?.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm')));
// Pause when showing reactions or reply input or state changed
useEffect(() => {
if (showReactions || showReplyInput || showViewers || paused) {
if (videoRef.current && !videoRef.current.paused) {
videoRef.current.pause();
}
} else {
if (videoRef.current && videoRef.current.paused && isVideo) {
videoRef.current.play().catch(() => { });
}
}
}, [showReactions, showReplyInput, showViewers, paused, isVideo]);
// Handle video play/pause sync with paused state
useEffect(() => {
if (!videoRef.current || !isVideo) return;
if (paused) {
videoRef.current.pause();
} else {
videoRef.current.play().catch(() => { });
}
}, [paused, isVideo]);
// Mute by default for viewers, unmute for story owner
useEffect(() => {
if (currentUser?.user.id === user?.id) {
setIsMuted(false);
}
}, [currentUser?.user.id, user?.id]);
useEffect(() => {
setUserIndex(initialUserIndex);
setStoryIndex(initialStoryIndex || 0);
setProgress(0);
setPaused(false);
viewedRef.current.clear();
setViewOverrides({});
}, [initialUserIndex, initialStoryIndex]);
const goNext = useCallback(() => {
if (!currentUser) return;
if (storyIndex < currentUser.stories.length - 1) {
setStoryIndex(s => s + 1);
setProgress(0);
} else if (userIndex < stories.length - 1) {
setUserIndex(u => u + 1);
setStoryIndex(0);
setProgress(0);
} else {
onClose();
}
}, [storyIndex, userIndex, currentUser, stories.length, onClose]);
const goPrev = useCallback(() => {
if (storyIndex > 0) {
setStoryIndex(s => s - 1);
setProgress(0);
} else if (userIndex > 0) {
setUserIndex(u => u - 1);
const prevUser = stories[userIndex - 1];
setStoryIndex(prevUser.stories.length - 1);
setProgress(0);
}
}, [storyIndex, userIndex, stories]);
const canGoPrev = storyIndex > 0 || userIndex > 0;
const canGoNext = (currentUser && storyIndex < currentUser.stories.length - 1) || userIndex < stories.length - 1;
useEffect(() => {
if (!currentStory || !currentStory.id) return;
if (currentUser.user.id === user?.id) return;
if (currentStory.viewed || viewedRef.current.has(currentStory.id)) return;
// console.log('[StoryViewer] Calling viewStory for:', currentStory.id);
viewedRef.current.add(currentStory.id);
const storyId = currentStory.id;
const viewCount = currentStory.viewCount || 0;
StoryApi.viewStory(storyId).then(() => {
// console.log('[StoryViewer] viewStory success, updating count to', viewCount + 1);
setViewOverrides(prev => ({
...prev,
[storyId]: {
viewCount: viewCount + 1,
viewed: true,
},
}));
}).catch(e => {
console.error('[StoryViewer] viewStory error:', e);
});
}, [currentStory?.id, currentUser?.user?.id, user?.id]);
useEffect(() => {
setProgress(0);
}, [storyIndex, userIndex]);
useEffect(() => {
if (paused || showReactions || showReplyInput || showViewers || !currentStory || isVideo) return;
const duration = STORY_DURATION;
const step = (TICK / duration) * 100;
timerRef.current = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
goNext();
return 0;
}
return prev + step;
});
}, TICK);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [storyIndex, userIndex, paused, showReactions, showReplyInput, showViewers, goNext, isVideo, currentStory]);
// Handle video progress
useEffect(() => {
const video = videoRef.current;
if (!video || !isVideo || paused || showReactions || showReplyInput || showViewers) return;
const interval = setInterval(() => {
if (video.duration) {
const p = (video.currentTime / video.duration) * 100;
setProgress(p);
}
}, TICK);
return () => clearInterval(interval);
}, [isVideo, paused, showReactions, showReplyInput, showViewers, storyIndex, userIndex]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowRight') goNext();
if (e.key === 'ArrowLeft') goPrev();
};
const socket = getSocket();
const handleStoryViewed = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number; ownerId: string }) => {
// console.log('[StoryViewer] story_viewed received:', data);
if (!currentStory || data.storyId !== currentStory.id) return;
// Only process if this user is the owner
if (data.ownerId !== user?.id) return;
setViewOverrides(prev => ({
...prev,
[data.storyId]: {
viewCount: data.viewCount,
viewed: prev[data.storyId]?.viewed || false
}
}));
if (showViewers) {
setViewers(prev => {
if (prev.some(v => v.userId === data.userId)) return prev;
return [...prev, {
userId: data.userId,
username: data.username,
displayName: data.displayName,
avatar: data.avatar,
viewedAt: data.viewedAt
}].sort((a, b) => new Date(b.viewedAt).getTime() - new Date(a.viewedAt).getTime());
});
}
};
const handleStoryReply = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string; ownerId: string }) => {
// console.log('[StoryViewer] story_reply received:', data);
// Only process if this user is the owner
if (data.ownerId !== user?.id) return;
// Could show notification or update UI
};
const handleStoryReaction = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; emoji: string; createdAt: string; ownerId: string }) => {
// console.log('[StoryViewer] story_reaction received:', data);
// Only process if this user is the owner
if (data.ownerId !== user?.id) return;
// Could show notification or update UI
};
window.addEventListener('keydown', onKey);
socket?.on('story_viewed', handleStoryViewed);
socket?.on('story_reply', handleStoryReply);
socket?.on('story_reaction', handleStoryReaction);
return () => {
window.removeEventListener('keydown', onKey);
socket?.off('story_viewed', handleStoryViewed);
socket?.off('story_reply', handleStoryReply);
socket?.off('story_reaction', handleStoryReaction);
};
}, [goNext, goPrev, onClose, currentStory?.id, showViewers]);
const handleDelete = async () => {
if (!currentStory) return;
const storyId = currentStory.id;
try {
await StoryApi.deleteStory(storyId);
if (currentUser.stories.length > 1) {
if (storyIndex >= currentUser.stories.length - 1) {
setStoryIndex(s => s - 1);
}
} else {
if (userIndex < stories.length - 1) {
setUserIndex(u => u + 1);
setStoryIndex(0);
} else {
onClose();
}
}
onRefresh();
} catch (e) {
console.error(e);
}
};
const toggleMute = () => {
setIsMuted(!isMuted);
if (videoRef.current) {
videoRef.current.muted = !isMuted;
}
};
const handleAddReaction = async (emoji: string) => {
if (!currentStory) return;
setShowReactions(false);
setLocalReactions(prev => ({
...prev,
[currentStory.id]: [...(prev[currentStory.id] || []), {
id: `${currentStory.id}-${user?.id}-${emoji}`,
userId: user?.id || '',
emoji,
createdAt: new Date().toISOString()
}]
}));
try {
await StoryApi.addStoryReaction(currentStory.id, emoji);
} catch (e) {
console.error('Add reaction error:', e);
}
};
const handleSendReply = async () => {
if (!currentStory || !replyText.trim() || sendingReply) return;
setSendingReply(true);
try {
await StoryApi.addStoryReply(currentStory.id, replyText.trim());
setReplyText('');
setShowReplyInput(false);
} catch (e) {
console.error('Send reply error:', e);
} finally {
setSendingReply(false);
}
};
if (!currentUser || !currentStory) {
onClose();
return null;
}
const timeAgo = (date: string) => {
const diff = (Date.now() - new Date(date).getTime()) / 1000;
if (diff < 60) return `${Math.floor(diff)}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
return `${Math.floor(diff / 3600)}h`;
};
const avatarUrl = currentUser.user.avatar
? getMediaUrl(currentUser.user.avatar)
: null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className="relative w-full max-w-[420px] h-full max-h-[85vh] rounded-2xl overflow-hidden select-none"
>
{isVideo ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<video
ref={videoRef}
src={getMediaUrl(currentStory.mediaUrl)}
className="w-full h-full object-contain"
autoPlay
muted={isMuted}
playsInline
onEnded={goNext}
/>
</div>
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<img
src={getMediaUrl(currentStory.mediaUrl)}
alt="story"
className="w-full h-full object-contain"
draggable={false}
/>
</div>
) : (
<div
className="w-full h-full flex items-center justify-center p-8"
style={{ background: currentStory.bgColor || '#6366f1' }}
>
<p className="text-white text-2xl font-bold text-center leading-relaxed drop-shadow-lg"
style={{ maxWidth: '90%', wordBreak: 'break-word' }}>
{currentStory.content}
</p>
</div>
)}
<div className="absolute top-0 left-0 right-0 flex gap-1 p-2 z-10">
{currentUser.stories.map((_, i) => (
<div key={i} className="flex-1 h-[3px] bg-white/30 rounded-full overflow-hidden">
<div
className="h-full bg-white rounded-full transition-none"
style={{
width: i < storyIndex ? '100%' : i === storyIndex ? `${progress}%` : '0%',
}}
/>
</div>
))}
</div>
<div className="absolute top-4 left-0 right-0 flex items-center gap-3 px-4 pt-2 z-10">
<Avatar
src={avatarUrl}
name={currentUser.user.displayName || currentUser.user.username}
size="sm"
className="ring-2 ring-white/20 rounded-full"
/>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-semibold truncate drop-shadow">
{currentUser.user.id === user?.id ? t('myStory') : currentUser.user.displayName || currentUser.user.username}
</p>
<p className="text-white/60 text-xs drop-shadow">{timeAgo(currentStory.createdAt)}</p>
</div>
<div className="flex items-center gap-2">
{currentUser.user.id === user?.id && (
<>
<button
onClick={(e) => {
e.stopPropagation();
if (showViewers) {
setShowViewers(false);
setPaused(false);
} else {
setPaused(true);
setShowViewers(true);
setViewersLoading(true);
StoryApi.getStoryViewers(currentStory.id).then(v => {
setViewers(v);
setViewersLoading(false);
}).catch(() => setViewersLoading(false));
}
}}
className="text-white/60 hover:text-white text-xs flex items-center gap-1 transition-colors p-1"
>
<Eye size={12} /> {currentStory.viewCount}
<ChevronUp size={10} className={`transition-transform ${showViewers ? 'rotate-180' : ''}`} />
</button>
<button onClick={handleDelete} className="text-white/60 hover:text-red-400 transition-colors p-1">
<Trash2 size={16} />
</button>
</>
)}
<button onClick={onClose} className="text-white/60 hover:text-white transition-colors p-1">
<X size={20} />
</button>
</div>
</div>
<div
className="absolute inset-0 flex z-[5]"
onMouseDown={() => setPaused(true)}
onMouseUp={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
onMouseLeave={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
onTouchStart={() => setPaused(true)}
onTouchEnd={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }}
>
<div className="w-1/3 h-full cursor-pointer" onClick={(e) => { e.stopPropagation(); goPrev(); }} />
<div className="w-1/3 h-full" />
<div className="w-1/3 h-full cursor-pointer" onClick={(e) => { e.stopPropagation(); goNext(); }} />
</div>
{canGoPrev && (
<button
onClick={(e) => { e.stopPropagation(); goPrev(); }}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center text-white/70 hover:bg-white/20 hover:text-white transition-all"
>
<ChevronLeft size={20} />
</button>
)}
{canGoNext && (
<button
onClick={(e) => { e.stopPropagation(); goNext(); }}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center text-white/70 hover:bg-white/20 hover:text-white transition-all"
>
<ChevronRight size={20} />
</button>
)}
{/* Bottom actions */}
<div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-4 z-10 px-4">
{/* Sound toggle for video - show for everyone */}
{isVideo && (
<button
onClick={(e) => { e.stopPropagation(); toggleMute(); }}
className="w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white/70 hover:text-white transition-colors"
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
)}
{/* Reply and reactions - only for non-owners */}
{currentUser.user.id !== user?.id && (
<>
<button
onClick={(e) => { e.stopPropagation(); setShowReplyInput(!showReplyInput); setShowReactions(false); }}
className={`w-10 h-10 rounded-full backdrop-blur-sm flex items-center justify-center transition-colors ${showReplyInput ? 'bg-accent text-white' : 'bg-black/50 text-white/70 hover:text-white'}`}
>
<MessageCircle size={20} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setShowReactions(!showReactions); setShowReplyInput(false); }}
className={`w-10 h-10 rounded-full backdrop-blur-sm flex items-center justify-center transition-colors ${showReactions ? 'bg-accent text-white' : 'bg-black/50 text-white/70 hover:text-white'}`}
>
<Smile size={20} />
</button>
</>
)}
</div>
<AnimatePresence>
{showReactions && currentUser.user.id !== user?.id && (
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 50, opacity: 0 }}
className="absolute bottom-20 left-0 right-0 z-20 flex justify-center gap-2 px-4"
onClick={(e) => e.stopPropagation()}
>
{STORY_EMOJIS.map(emoji => (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleAddReaction(emoji); }}
className="w-12 h-12 rounded-full bg-black/70 backdrop-blur-sm flex items-center justify-center text-2xl hover:scale-125 transition-transform"
>
{emoji}
</button>
))}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showReplyInput && currentUser.user.id !== user?.id && (
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 50, opacity: 0 }}
className="absolute bottom-20 left-0 right-0 z-20 px-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex gap-2">
<input
type="text"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSendReply(); }}
placeholder={t('replyToStory') || 'Reply to story...'}
className="flex-1 bg-black/70 backdrop-blur-sm border border-white/20 rounded-full px-4 py-2 text-sm text-white placeholder-white/50 focus:outline-none focus:border-accent"
autoFocus
/>
<button
onClick={handleSendReply}
disabled={!replyText.trim() || sendingReply}
className="px-4 py-2 rounded-full bg-accent text-white text-sm font-medium disabled:opacity-50"
>
{t('send') || 'Send'}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showViewers && currentUser.user.id === user?.id && (
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="absolute bottom-0 left-0 right-0 z-20 bg-black/90 backdrop-blur-xl rounded-t-2xl border-t border-white/10 max-h-[50%] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-white text-sm font-semibold flex items-center gap-2">
<Eye size={14} /> {t('storyViewers')} ({currentStory.viewCount})
</h4>
<button
onClick={() => { setShowViewers(false); setPaused(false); }}
className="text-white/60 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{viewersLoading ? (
<div className="text-white/40 text-sm text-center py-4">{t('sending')}</div>
) : viewers.length === 0 ? (
<div className="text-white/40 text-sm text-center py-4">{t('noViewers')}</div>
) : (
<div className="space-y-2">
{viewers.map((v) => (
<div key={v.userId} className="flex items-center gap-3 py-1.5">
<Avatar
src={v.avatar ? getMediaUrl(v.avatar) : null}
name={v.displayName || v.username}
size="sm"
className="rounded-full"
/>
<div className="flex-1 min-w-0">
<p className="text-white text-sm truncate">{v.displayName || v.username}</p>
<p className="text-white/40 text-xs">@{v.username}</p>
</div>
<span className="text-white/30 text-xs">{timeAgo(v.viewedAt)}</span>
</div>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}
interface CreateStoryModalProps {
onClose: () => void;
onCreated: () => void;
}
export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) {
const { t } = useLang();
const [mode, setMode] = useState<'text' | 'image'>('text');
const [text, setText] = useState('');
const [bgColor, setBgColor] = useState('#6366f1');
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImageFile(file);
if (file.type.startsWith('video/')) {
setImagePreview(URL.createObjectURL(file));
setMode('image');
} else {
const reader = new FileReader();
reader.onload = () => setImagePreview(reader.result as string);
reader.readAsDataURL(file);
setMode('image');
}
};
const handleCreate = async () => {
if (mode === 'text' && !text.trim()) return;
if (mode === 'image' && !imageFile) return;
setIsUploading(true);
try {
let mediaUrl: string | undefined;
if (imageFile) {
const result = await ChatApi.uploadFile(imageFile);
mediaUrl = result.url;
}
await StoryApi.createStory({
type: imageFile?.type.startsWith('video/') ? 'video' : mode,
content: mode === 'text' ? text.trim() : undefined,
bgColor: mode === 'text' ? bgColor : undefined,
mediaUrl,
});
onCreated();
onClose();
} catch (e) {
console.error('Create story error:', e);
} finally {
setIsUploading(false);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="w-full max-w-[400px] rounded-2xl glass-strong border border-white/10 overflow-hidden"
>
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{t('newStory')}</h3>
<button onClick={onClose} className="text-zinc-400 hover:text-white">
<X size={18} />
</button>
</div>
<div className="flex border-b border-white/10">
<button
onClick={() => setMode('text')}
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'text' ? 'text-knot-400 border-b-2 border-knot-400' : 'text-zinc-400'}`}
>
{t('textStory')}
</button>
<button
onClick={() => setMode('image')}
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'image' ? 'text-knot-400 border-b-2 border-knot-400' : 'text-zinc-400'}`}
>
{t('mediaStory')}
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={handleImageSelect}
/>
<div className="p-4">
{mode === 'text' ? (
<>
<div
className="w-full h-48 rounded-xl flex items-center justify-center p-4 mb-4 transition-colors"
style={{ background: bgColor }}
>
<p className="text-white text-lg font-bold text-center break-words max-w-full">
{text || t('typeYourStory')}
</p>
</div>
<textarea
value={text}
onChange={e => setText(e.target.value)}
placeholder={t('typeYourStory')}
maxLength={200}
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-zinc-200 resize-none h-20 mb-3 focus:outline-none focus:border-knot-500/50"
/>
<div className="flex flex-wrap gap-2 mb-3">
{STORY_BG_COLORS.map(c => (
<button
key={c}
onClick={() => setBgColor(c)}
className={`w-7 h-7 rounded-full transition-transform ${bgColor === c ? 'scale-125 ring-2 ring-white/50' : 'hover:scale-110'}`}
style={{ background: c }}
/>
))}
</div>
</>
) : (
<>
{imagePreview ? (
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden bg-black flex items-center justify-center">
{imageFile?.type.startsWith('video/') ? (
<video src={imagePreview} className="w-full h-full object-contain" />
) : (
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
)}
<button
onClick={() => { setImageFile(null); setImagePreview(null); }}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 flex items-center justify-center text-white"
>
<X size={14} />
</button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
className="w-full h-48 rounded-xl border-2 border-dashed border-white/20 flex items-center justify-center mb-4 text-zinc-400 hover:text-white hover:border-white/40 transition-colors"
>
<Plus size={32} />
</button>
)}
</>
)}
<button
onClick={handleCreate}
disabled={isUploading || (mode === 'text' && !text.trim()) || (mode === 'image' && !imageFile)}
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{isUploading ? '...' : t('publishStory')}
</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,59 @@
import { httpClient } from '../../../core/infrastructure/httpClient';
import type { User, UserPresence } from '../../../core/domain/types';
export class UserApi {
static async searchUsers(query: string) {
return httpClient.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
}
static async getUser(id: string) {
return httpClient.request<User>(`/users/${id}`);
}
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
return httpClient.request<User>('/users/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}
static async updateSettings(settings: any) {
return httpClient.request('/users/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
}
static async uploadAvatar(file: File) {
const formData = new FormData();
formData.append('avatar', file);
return httpClient.request<User>('/users/avatar', {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
const formData = new FormData();
formData.append('avatar', file);
formData.append('cropX', cropData.x.toString());
formData.append('cropY', cropData.y.toString());
formData.append('cropWidth', cropData.width.toString());
formData.append('cropHeight', cropData.height.toString());
return httpClient.request<User>('/users/avatar/crop', {
method: 'POST',
body: formData,
timeout: 120_000,
});
}
static async removeAvatar() {
return httpClient.request<User>('/users/avatar', { method: 'DELETE' });
}
static async getIceServers() {
return httpClient.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
}
}

View File

@@ -0,0 +1,262 @@
import { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Upload, Check, Loader2, MessageSquare, AlertCircle } from 'lucide-react';
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';
interface TelegramImportModalProps {
isOpen: boolean;
onClose: () => void;
friends: FriendWithId[];
}
export default function TelegramImportModal({ isOpen, onClose, friends }: TelegramImportModalProps) {
const { t } = useLang();
const { user } = useAuthStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<1 | 2 | 3>(1); // 1: upload, 2: map, 3: loading/done
const [file, setFile] = useState<File | null>(null);
const [token, setToken] = useState<string | null>(null);
const [names, setNames] = useState<string[]>([]);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [groupName, setGroupName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [importedState, setImportedState] = useState<{ count: number; text: string } | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
if (!selectedFile.name.endsWith('.zip')) {
setError('Пожалуйста, выберите ZIP-архив экспорта Telegram.');
return;
}
setFile(selectedFile);
setError(null);
setLoading(true);
try {
const data = await AppApi.analyzeTelegramImport(selectedFile) as any;
setToken(data.token);
setNames(data.names);
// Auto-map if possible
const initialMap: Record<string, string> = {};
data.names.forEach((name: string) => {
initialMap[name] = '';
});
setMapping(initialMap);
setStep(2);
} catch (err: any) {
console.error(err);
setError(err.message || 'Ошибка загрузки файла');
} finally {
setLoading(false);
}
};
const handleExecute = async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const res = await AppApi.executeTelegramImport({ token, mapping, groupName }) as any;
setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' });
setStep(3);
} catch (err: any) {
console.error(err);
setError(err.message || 'Ошибка импорта');
setStep(1);
} finally {
setLoading(false);
}
};
const reset = () => {
setStep(1);
setFile(null);
setToken(null);
setNames([]);
setMapping({});
setGroupName('');
setError(null);
setImportedState(null);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleClose = () => {
reset();
onClose();
};
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={step === 3 && importedState ? handleClose : undefined}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
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]"
>
{/* 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>
<button
onClick={handleClose}
className="p-2 -mr-2 text-zinc-400 hover:text-white hover:bg-white/10 rounded-xl transition-all"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{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>
)}
{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>
<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>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".zip"
className="hidden"
/>
<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"
>
{loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<Upload size={18} />
Выбрать ZIP-архив
</>
)}
</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 они соответствуют. Одно из имён должно принадлежать вам.
</p>
</div>
<div className="space-y-4">
{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>
<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"
>
<option value="">-- Выберите пользователя --</option>
<option value={user?.id}>Это я ({user?.displayName || user?.username})</option>
{friends.map(f => (
<option key={f.id} value={f.id}>
Контакт: {f.displayName || f.username}
</option>
))}
</select>
</div>
))}
</div>
{names.length > 2 && (
<div className="pt-2">
<h4 className="text-sm font-medium text-white mb-2">Название для группового чата</h4>
<input
type="text"
placeholder="Например, Моя группа"
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"
/>
</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>
<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"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
Импортировать
</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>
<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"
>
Закрыть
</button>
</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}

File diff suppressed because it is too large Load Diff