Анимации, профиль, медиа
This commit is contained in:
@@ -1591,7 +1591,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
{displayAvatar ? (
|
||||
<img src={displayAvatar} alt="" className="relative w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
<div className="relative w-10 h-10 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-sm shadow-inner">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
@@ -1735,7 +1735,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
{displayAvatar ? (
|
||||
<img src={displayAvatar} alt="" className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover border-4 border-knot-500/30" />
|
||||
) : (
|
||||
<div className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-3xl sm:text-4xl font-bold border-4 border-knot-500/30">
|
||||
<div className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-3xl sm:text-4xl font-black border-4 border-primary/30 shadow-2xl">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
@@ -1851,7 +1851,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
{displayAvatar ? (
|
||||
<img src={displayAvatar} alt="" className="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-inner" />
|
||||
) : (
|
||||
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-3xl sm:text-4xl shadow-inner">
|
||||
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-3xl sm:text-4xl shadow-2xl border-4 border-primary/20">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -471,7 +471,7 @@ export default function ChatPage() {
|
||||
>
|
||||
<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">
|
||||
<div className="relative w-24 h-24 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-4xl font-black text-on-primary uppercase overflow-hidden shadow-2xl">
|
||||
{incomingGroupCall.callerInfo?.avatar ? (
|
||||
<img src={incomingGroupCall.callerInfo.avatar} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
|
||||
@@ -649,7 +649,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{showSearch ? <span className="material-symbols-outlined">close</span> : <span className="material-symbols-outlined">search</span>}
|
||||
</button>
|
||||
|
||||
{!isFavorites && config?.enableCalls && (
|
||||
{!isFavorites && config?.webRtc?.enabled && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1002,7 +1002,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{activeChat && <MessageInput chatId={activeChat} />}
|
||||
|
||||
{(() => {
|
||||
const handleJumpToMessage = async (msgId: string, cleanup?: () => void) => {
|
||||
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
|
||||
cleanup?.();
|
||||
const tryScroll = () => {
|
||||
const el = document.getElementById(`msg-${msgId}`);
|
||||
@@ -1019,21 +1019,45 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
if (!activeChat) return;
|
||||
|
||||
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('info', 'Поиск сообщения в истории...');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('info', lang === 'ru' ? 'Поиск сообщения в истории...' : 'Searching message in history...');
|
||||
|
||||
const chatStore = useChatStore.getState();
|
||||
let found = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (chatStore.hasMoreMessages[activeChat] === false) break;
|
||||
const targetDate = targetCreatedAt ? new Date(targetCreatedAt).getTime() : 0;
|
||||
|
||||
// Smart timeline-based search
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const chatMessages = chatStore.messages[activeChat] || [];
|
||||
const oldestLoaded = chatMessages.length > 0 ? new Date(chatMessages[0].createdAt).getTime() : Date.now();
|
||||
|
||||
// If target is newer than oldest loaded, and not found, maybe it's in a gap or we need to keep loading?
|
||||
// Actually target is almost always older if not found.
|
||||
// If we don't have targetCreatedAt, we guess (up to 100 attempts)
|
||||
if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) {
|
||||
// Should have been found by tryScroll, but lets try one last time
|
||||
if (tryScroll()) { found = true; break; }
|
||||
}
|
||||
|
||||
if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break;
|
||||
|
||||
await chatStore.loadMessages(activeChat, false, true);
|
||||
// Give React 150ms to render the new messages
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
if (tryScroll()) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Stop if we have gone way past the target date
|
||||
if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
|
||||
// We are 1 hour before the message and still haven't found it? might be deleted
|
||||
if (i > 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', 'Сообщение слишком старое');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', lang === 'ru' ? 'Сообщение не найдено' : 'Message not found');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1046,7 +1070,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
userId={profileUserId}
|
||||
chatId={activeChat || undefined}
|
||||
onClose={() => setProfileUserId(null)}
|
||||
onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setProfileUserId(null))}
|
||||
onGoToMessage={(msgId: any, createdAt: string) => handleJumpToMessage(msgId, () => setProfileUserId(null), createdAt)}
|
||||
isSelf={profileUserId === user?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { Search, TrendingUp, Loader2, Clock, Grid } from 'lucide-react';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { AppApi } from '../../../../core/infrastructure/appApi';
|
||||
@@ -17,77 +17,227 @@ interface KlipyGif {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface GifCategory {
|
||||
category: string;
|
||||
query: string;
|
||||
preview_url: string;
|
||||
}
|
||||
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void;
|
||||
onSelectGif?: (url: string, preview: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type GifMode = 'recent' | 'trending' | 'categories' | 'search';
|
||||
|
||||
const CATEGORY_TRANSLATIONS: Record<string, string> = {
|
||||
'hello': 'Привет',
|
||||
'lol': 'Лол',
|
||||
'love': 'Любовь',
|
||||
'happy birthday': 'С днем рождения',
|
||||
'thank you': 'Спасибо',
|
||||
'excited': 'Восторг',
|
||||
'smile': 'Улыбка',
|
||||
'aww': 'Мило',
|
||||
'high five': 'Дай пять',
|
||||
'good morning': 'Доброе утро',
|
||||
'good night': 'Спокойной ночи',
|
||||
'yes': 'Да',
|
||||
'no': 'Нет',
|
||||
'ok': 'Ок',
|
||||
'sorry': 'Прости',
|
||||
'please': 'Пожалуйста',
|
||||
'wow': 'Вау',
|
||||
'dance': 'Танцы',
|
||||
'hungry': 'Голоден',
|
||||
'scared': 'Страшно',
|
||||
'tired': 'Устал',
|
||||
'sad': 'Грустно',
|
||||
'party': 'Вечеринка',
|
||||
'cry': 'Плачу',
|
||||
'cool': 'Круто'
|
||||
};
|
||||
|
||||
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
|
||||
const { lang, t } = useLang();
|
||||
const { config } = useAuthStore();
|
||||
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
|
||||
|
||||
// Persist only the main tab type, but reset GIF sub-mode to 'recent' on entry
|
||||
const [tab, setTab] = useState<'emoji' | 'gif'>(() => {
|
||||
return (localStorage.getItem('emojiPicker_tab') as any) || 'emoji';
|
||||
});
|
||||
const [gifMode, setGifMode] = useState<GifMode>('recent');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('emojiPicker_tab', tab);
|
||||
}, [tab]);
|
||||
|
||||
const [gifQuery, setGifQuery] = useState('');
|
||||
const [gifs, setGifs] = useState<KlipyGif[]>([]);
|
||||
const [gifLoading, setGifLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
|
||||
const gifSearchRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const initialFetchDone = useRef(false);
|
||||
const [trendingPage, setTrendingPage] = useState(1);
|
||||
const [trendingHasMore, setTrendingHasMore] = useState(true);
|
||||
|
||||
const [recentGifs, setRecentGifs] = useState<KlipyGif[]>([]);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
const [recentHasMore, setRecentHasMore] = useState(true);
|
||||
|
||||
const [categories, setCategories] = useState<GifCategory[]>([]);
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(false);
|
||||
|
||||
const gifSearchRef = useRef<HTMLInputElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const initialFetchDone = useRef({ trending: false, categories: false, recent: 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 [];
|
||||
let list: any[] = [];
|
||||
if (d.data && Array.isArray(d.data.data)) list = d.data.data;
|
||||
else if (Array.isArray(d.data)) list = d.data;
|
||||
else if (Array.isArray(d.result)) list = d.result;
|
||||
else if (d.result && Array.isArray(d.result.data)) list = d.result.data;
|
||||
else if (Array.isArray(d)) list = d;
|
||||
else if (Array.isArray(d.gifs)) list = d.gifs;
|
||||
|
||||
return list.filter(g => g && typeof g === 'object' && (g.id || g.media || g.files || g.file));
|
||||
};
|
||||
|
||||
// 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);
|
||||
});
|
||||
const checkHasMore = (d: any): boolean => {
|
||||
if (!d) return false;
|
||||
if (d.data && typeof d.data.has_next === 'boolean') return d.data.has_next;
|
||||
const meta = d.data?.meta || d.meta || d.result?.meta;
|
||||
if (meta) {
|
||||
if (typeof meta.has_more === 'boolean') return meta.has_more;
|
||||
if (typeof meta.has_next === 'boolean') return meta.has_next;
|
||||
if (typeof meta.last_page === 'number' && typeof meta.current_page === 'number') {
|
||||
return meta.current_page < meta.last_page;
|
||||
}
|
||||
}
|
||||
}, [tab, config?.enableKlipy]);
|
||||
const count = extractGifs(d).length;
|
||||
return count >= 20;
|
||||
};
|
||||
|
||||
const searchGifs = useCallback((q: string) => {
|
||||
if (!config?.enableKlipy || !q.trim()) {
|
||||
const fetchTrending = useCallback((p: number) => {
|
||||
if (!config?.klipy?.enabled) return;
|
||||
setGifLoading(true);
|
||||
AppApi.getTrendingGifs(p)
|
||||
.then(d => {
|
||||
const newGifs = extractGifs(d);
|
||||
setTrendingGifs(prev => p === 1 ? newGifs : [...prev, ...newGifs]);
|
||||
setTrendingHasMore(checkHasMore(d));
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setGifLoading(false));
|
||||
}, [config?.klipy?.enabled]);
|
||||
|
||||
const fetchRecent = useCallback((p: number) => {
|
||||
if (!config?.klipy?.enabled) return;
|
||||
setGifLoading(true);
|
||||
AppApi.getRecentGifs(p)
|
||||
.then(d => {
|
||||
const newGifs = extractGifs(d);
|
||||
setRecentGifs(prev => p === 1 ? newGifs : [...prev, ...newGifs]);
|
||||
setRecentHasMore(checkHasMore(d));
|
||||
|
||||
// Auto-switch to trending if history is empty on first load
|
||||
if (p === 1 && newGifs.length === 0 && gifMode === 'recent') {
|
||||
setGifMode('trending');
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setGifLoading(false));
|
||||
}, [config?.klipy?.enabled, gifMode]);
|
||||
|
||||
const fetchCategories = useCallback(() => {
|
||||
if (!config?.klipy?.enabled) return;
|
||||
setCategoriesLoading(true);
|
||||
AppApi.getGifCategories('RU')
|
||||
.then(d => {
|
||||
if (d.data && Array.isArray(d.data.categories)) {
|
||||
setCategories(d.data.categories);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setCategoriesLoading(false));
|
||||
}, [config?.klipy?.enabled]);
|
||||
|
||||
const searchGifs = useCallback((q: string, p: number) => {
|
||||
if (!config?.klipy?.enabled || !q.trim()) {
|
||||
setGifs([]);
|
||||
setGifLoading(false);
|
||||
return;
|
||||
}
|
||||
setGifLoading(true);
|
||||
AppApi.searchKlipyGifs(q)
|
||||
AppApi.searchKlipyGifs(q, p)
|
||||
.then(d => {
|
||||
setGifs(extractGifs(d));
|
||||
setGifLoading(false);
|
||||
const newGifs = extractGifs(d);
|
||||
setGifs(prev => p === 1 ? newGifs : [...prev, ...newGifs]);
|
||||
setHasMore(checkHasMore(d));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Klipy search error:', e);
|
||||
setGifs([]);
|
||||
setGifLoading(false);
|
||||
});
|
||||
}, [config?.enableKlipy]);
|
||||
.catch(console.error)
|
||||
.finally(() => setGifLoading(false));
|
||||
}, [config?.klipy?.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'gif' && config?.klipy?.enabled) {
|
||||
if (!initialFetchDone.current.trending) {
|
||||
initialFetchDone.current.trending = true;
|
||||
fetchTrending(1);
|
||||
}
|
||||
if (!initialFetchDone.current.categories) {
|
||||
initialFetchDone.current.categories = true;
|
||||
fetchCategories();
|
||||
}
|
||||
if (!initialFetchDone.current.recent) {
|
||||
initialFetchDone.current.recent = true;
|
||||
fetchRecent(1);
|
||||
}
|
||||
}
|
||||
}, [tab, config?.klipy?.enabled, fetchTrending, fetchCategories, fetchRecent]);
|
||||
|
||||
// Handle switching to recent tab to refresh it
|
||||
useEffect(() => {
|
||||
if (tab === 'gif' && gifMode === 'recent') {
|
||||
fetchRecent(1);
|
||||
}
|
||||
}, [gifMode, tab, fetchRecent]);
|
||||
|
||||
const handleGifSearch = (q: string) => {
|
||||
setGifQuery(q);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
if (!q.trim()) {
|
||||
setGifMode('trending');
|
||||
return;
|
||||
}
|
||||
setGifMode('search');
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => searchGifs(q), 400);
|
||||
debounceRef.current = setTimeout(() => searchGifs(q, 1), 400);
|
||||
};
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (gifLoading) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 400) {
|
||||
if (gifMode === 'search' && hasMore) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
searchGifs(gifQuery, nextPage);
|
||||
} else if (gifMode === 'trending' && trendingHasMore) {
|
||||
const n = trendingPage + 1;
|
||||
setTrendingPage(n);
|
||||
fetchTrending(n);
|
||||
} else if (gifMode === 'recent' && recentHasMore) {
|
||||
const n = recentPage + 1;
|
||||
setRecentPage(n);
|
||||
fetchRecent(n);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getGifUrl = (gif: any): string => {
|
||||
@@ -109,11 +259,13 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
const preview = getGifPreview(gif, url);
|
||||
if (onSelectGif && url) {
|
||||
onSelectGif(url, preview);
|
||||
// Mark as shared with Klipy trigger API
|
||||
AppApi.markGifShared(gif.id, gifMode === 'search' ? gifQuery : '')
|
||||
.then(() => fetchRecent(1)) // Refresh history immediately after sharing
|
||||
.catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
const displayGifs = gifQuery.trim() ? gifs : trendingGifs;
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
@@ -122,7 +274,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
const el = anchorRef.current?.parentElement;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const w = tab === 'gif' ? 360 : 352;
|
||||
const w = 400;
|
||||
let left = rect.right - w;
|
||||
if (left < 8) left = 8;
|
||||
setPos({ top: rect.top - 8, left });
|
||||
@@ -132,7 +284,13 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [tab]);
|
||||
|
||||
const pickerWidth = tab === 'gif' ? 360 : 352;
|
||||
const displayGifs = gifMode === 'search' ? gifs : gifMode === 'recent' ? recentGifs : trendingGifs;
|
||||
const currentHasMore = gifMode === 'search' ? hasMore : gifMode === 'recent' ? recentHasMore : trendingHasMore;
|
||||
|
||||
const translateCategory = (cat: string) => {
|
||||
if (lang !== 'ru') return cat;
|
||||
return CATEGORY_TRANSLATIONS[cat.toLowerCase()] || cat;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -141,98 +299,163 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9990]" onClick={onClose} />
|
||||
<div
|
||||
className="fixed z-[9991] rounded-xl shadow-2xl border border-border/40"
|
||||
className="fixed z-[9991] rounded-[2rem] shadow-[0_32px_64px_-16px_rgba(0,0,0,0.6)] border border-white/10 overflow-hidden flex flex-col backdrop-blur-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: pickerWidth,
|
||||
height: tab === 'gif' ? 435 : undefined,
|
||||
width: 400,
|
||||
height: 520,
|
||||
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
|
||||
left: pos ? pos.left : undefined,
|
||||
background: '#17212b',
|
||||
background: 'rgba(19, 19, 19, 0.85)',
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border/40">
|
||||
{/* Main Tabs */}
|
||||
<div className="flex p-2 gap-1 bg-black/20 backdrop-blur-md flex-shrink-0">
|
||||
<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'}`}
|
||||
className={`flex-1 py-3 text-[12px] font-black uppercase tracking-widest rounded-xl transition-all duration-300 ${tab === 'emoji' ? 'bg-primary/20 text-primary shadow-[0_0_20px_rgba(154,203,255,0.15)]' : 'text-zinc-500 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
{lang === 'ru' ? 'Эмодзи' : 'Emoji'}
|
||||
{lang === 'ru' ? 'Эмодзи' : 'Emojis'}
|
||||
</button>
|
||||
{config?.enableKlipy && onSelectGif && (
|
||||
{config?.klipy?.enabled && (
|
||||
<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'}`}
|
||||
className={`flex-1 py-3 text-[12px] font-black uppercase tracking-widest rounded-xl transition-all duration-300 ${tab === 'gif' ? 'bg-primary/20 text-primary shadow-[0_0_20px_rgba(154,203,255,0.15)]' : 'text-zinc-500 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
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}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
|
||||
theme="dark"
|
||||
locale={lang === 'ru' ? 'ru' : 'en'}
|
||||
set="native"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
perLine={10}
|
||||
emojiSize={26}
|
||||
emojiButtonSize={34}
|
||||
maxFrequentRows={2}
|
||||
navPosition="top"
|
||||
dynamicWidth={false}
|
||||
background="transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GIF tab */}
|
||||
{config?.enableKlipy && tab === 'gif' && (
|
||||
<div className="flex flex-col h-[calc(100%-41px)]">
|
||||
<div className="p-2">
|
||||
{config?.klipy?.enabled && tab === 'gif' && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Search Bar */}
|
||||
<div className="px-3 pt-2 pb-1 flex-shrink-0">
|
||||
<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"
|
||||
placeholder={lang === 'ru' ? 'Поиск GIF...' : 'Search GIFs...'}
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-xl bg-white/5 text-xs text-white placeholder-zinc-500 border border-white/5 focus:border-primary/50 outline-none transition-all shadow-inner"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Sub-Tabs */}
|
||||
<div className="flex items-center gap-1 px-3 py-1 bg-black/10 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setGifMode('recent'); setGifQuery(''); }}
|
||||
className={`p-2 rounded-lg transition-all ${gifMode === 'recent' ? 'text-primary bg-primary/20 shadow-lg' : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'}`}
|
||||
title={lang === 'ru' ? 'История' : 'History'}
|
||||
>
|
||||
<Clock size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setGifMode('trending'); setGifQuery(''); }}
|
||||
className={`p-2 rounded-lg transition-all ${gifMode === 'trending' ? 'text-primary bg-primary/20 shadow-lg' : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'}`}
|
||||
title={lang === 'ru' ? 'Тренды' : 'Trending'}
|
||||
>
|
||||
<TrendingUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setGifMode('categories'); setGifQuery(''); }}
|
||||
className={`p-2 rounded-lg transition-all ${gifMode === 'categories' ? 'text-primary bg-primary/20 shadow-lg' : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'}`}
|
||||
title={lang === 'ru' ? 'Категории' : 'Categories'}
|
||||
>
|
||||
<Grid size={16} />
|
||||
</button>
|
||||
{gifMode === 'search' && (
|
||||
<div className="flex items-center gap-2 px-3 py-1 ml-auto rounded-full bg-primary/10 border border-primary/20 animate-in fade-in slide-in-from-right-2">
|
||||
<Search size={10} className="text-primary" />
|
||||
<span className="text-[10px] text-primary font-black uppercase truncate max-w-[100px] tracking-widest">{gifQuery}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-2 pt-1 pb-2 scroll-smooth custom-scrollbar"
|
||||
>
|
||||
{gifMode === 'categories' ? (
|
||||
categoriesLoading ? (
|
||||
<div className="flex items-center justify-center py-20"><Loader2 size={32} className="text-primary animate-spin opacity-50" /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 p-1">
|
||||
{categories.map((cat, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { setGifQuery(cat.query); handleGifSearch(cat.query); }}
|
||||
className="relative aspect-[16/9] rounded-xl overflow-hidden group hover:ring-2 ring-primary/50 transition-all border border-white/5 bg-zinc-900 shadow-lg"
|
||||
>
|
||||
<img src={cat.preview_url} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 opacity-60" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/80 transition-all duration-300">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)]">{translateCategory(cat.category)}</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : displayGifs.length === 0 && gifLoading ? (
|
||||
<div className="flex items-center justify-center py-10"><Loader2 size={32} className="text-primary animate-spin opacity-50" /></div>
|
||||
) : (
|
||||
<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 className="grid grid-cols-3 gap-1">
|
||||
{displayGifs.map((gif, idx) => (
|
||||
<button
|
||||
key={`${gif.id}-${idx}-${gifMode}`}
|
||||
onClick={() => { pickGif(gif); onClose(); }}
|
||||
className="aspect-square w-full rounded-xl overflow-hidden hover:brightness-110 active:scale-95 transition-all bg-white/5 shadow-md border border-white/5 relative group"
|
||||
>
|
||||
<img
|
||||
src={getGifPreview(gif, getGifUrl(gif))}
|
||||
alt={gif.title || 'GIF'}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{gifLoading && (
|
||||
<div className="flex items-center justify-center py-6"><Loader2 size={24} className="text-primary animate-spin" /></div>
|
||||
)}
|
||||
{!currentHasMore && !gifLoading && displayGifs.length > 0 && (
|
||||
<p className="text-center text-[10px] text-zinc-600 py-4 uppercase tracking-widest font-black leading-none opacity-50">{lang === 'ru' ? 'Больше нет результатов' : 'No more results'}</p>
|
||||
)}
|
||||
{!gifLoading && displayGifs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600 animate-in fade-in zoom-in-95 duration-500">
|
||||
<Clock size={32} className="opacity-20 mb-3" />
|
||||
<p className="text-xs font-bold uppercase tracking-widest opacity-40">{gifMode === 'recent' ? (lang === 'ru' ? 'Пусто в истории' : 'Empty history') : (lang === 'ru' ? 'Ничего не найдено' : 'Nothing found')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -362,7 +362,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
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">
|
||||
<div className="w-32 h-32 rounded-full bg-linear-to-br from-primary to-primary-container flex items-center justify-center text-on-primary font-black text-4xl shadow-inner">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
@@ -577,7 +577,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
{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">
|
||||
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
|
||||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
@@ -616,7 +616,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
{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">
|
||||
<div className="w-9 h-9 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
|
||||
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -314,7 +314,7 @@ function MessageBubble({
|
||||
reactionGroups[r.emoji].avatars.push({
|
||||
url: r.user?.avatar,
|
||||
initials: displayName[0].toUpperCase(),
|
||||
colorClass: generateAvatarColor(displayName)
|
||||
colorClass: 'from-primary/80 to-primary-container'
|
||||
});
|
||||
}
|
||||
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
|
||||
@@ -338,7 +338,7 @@ function MessageBubble({
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sky-400 hover:underline"
|
||||
className="text-white underline decoration-white/30 underline-offset-2 hover:decoration-white transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{part}
|
||||
@@ -400,7 +400,7 @@ function MessageBubble({
|
||||
{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">
|
||||
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
|
||||
{senderName[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
@@ -847,7 +847,7 @@ function MessageBubble({
|
||||
{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">
|
||||
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
|
||||
{senderName[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -764,7 +764,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-400/20 to-purple-500/20 flex items-center justify-center ring-1 ring-knot-400/30 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<div className="w-10 h-10 rounded-full bg-linear-to-br from-primary/20 to-primary-container/20 flex items-center justify-center ring-1 ring-primary/30 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<ImageIcon size={18} className="text-knot-400" />
|
||||
</div>
|
||||
{t('photoVideo')}
|
||||
@@ -833,7 +833,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
i === mentionIndex ? 'bg-primary/20 text-white' : 'text-zinc-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[10px] font-bold">
|
||||
<div className="w-7 h-7 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-[10px] font-black shadow-inner">
|
||||
{(m.user.displayName || m.user.userName || m.user.username || '?')[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { ChatApi } from '../../infrastructure/chatApi';
|
||||
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||
import { FriendApi } from '../../../friends/infrastructure/friendApi';
|
||||
@@ -112,10 +112,10 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
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="w-full max-w-md rounded-[2.5rem] bg-surface-container/60 backdrop-blur-3xl shadow-[0_48px_80px_-16px_rgba(0,0,0,0.7)] border border-white/5 overflow-hidden flex flex-col" 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">
|
||||
<div className="flex items-center justify-between p-7 pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{mode !== 'personal' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -125,12 +125,12 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
setSelectedUsers([]);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
className="p-2 rounded-xl text-on-surface-variant/40 hover:text-white hover:bg-white/10 transition-all active:scale-95"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
<h2 className="text-xl font-black uppercase tracking-tight text-white/90">
|
||||
{mode === 'personal'
|
||||
? t('newChatTitle')
|
||||
: mode === 'group-select'
|
||||
@@ -140,23 +140,25 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
className="p-2 rounded-xl text-on-surface-variant/40 hover:text-white hover:bg-white/10 transition-all active:scale-95"
|
||||
>
|
||||
<X size={18} />
|
||||
<X size={20} />
|
||||
</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 className="group relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('groupNamePlaceholder')}
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="w-full px-6 py-4 rounded-2xl bg-surface-container-highest/30 text-sm text-white placeholder-zinc-500 border border-white/5 focus:border-primary/40 focus:bg-surface-container-highest/50 outline-none transition-all shadow-inner"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2">
|
||||
{t('membersCount')} ({selectedUsers.length}):
|
||||
@@ -170,7 +172,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
{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">
|
||||
<div className="w-5 h-5 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-[9px] font-black shadow-inner">
|
||||
{(u.displayName || u.userName || u.username || '?')[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
@@ -188,13 +190,13 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
<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"
|
||||
className="w-full py-4 rounded-2xl bg-primary text-on-primary text-[13px] font-black uppercase tracking-widest transition-all hover:brightness-110 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2 shadow-[0_20px_30px_-10px_rgba(154,203,255,0.2)]"
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Users size={16} />
|
||||
<Users size={18} />
|
||||
{t('createGroup')}
|
||||
</>
|
||||
)}
|
||||
@@ -207,15 +209,15 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
{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"
|
||||
className="w-full flex items-center gap-4 px-4 py-3.5 rounded-2xl bg-surface-container-highest/20 hover:bg-surface-container-highest/40 transition-all border border-white/5 active:scale-[0.98] group"
|
||||
>
|
||||
<div className="w-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 className="w-11 h-11 rounded-full bg-linear-to-br from-primary to-primary-container flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
|
||||
<Users size={20} className="text-on-primary" />
|
||||
</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 className="text-[13px] font-black uppercase tracking-tight text-white/90">{t('createGroup')}</p>
|
||||
<p className="text-[11px] text-zinc-500 font-medium tracking-wide">
|
||||
{t('upTo200').replace('200', String(config?.chats?.maxGroupParticipants || 500))}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -237,8 +239,8 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<div className="relative group">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
@@ -248,7 +250,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
}
|
||||
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"
|
||||
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-surface-container-highest/20 text-sm text-white placeholder-zinc-500 border border-white/5 focus:border-primary/40 outline-none transition-all focus:bg-surface-container-highest/30"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -261,45 +263,47 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
<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 || u.username || '?')[0]?.toUpperCase() || '?'}
|
||||
<div className="space-y-1 px-1">
|
||||
{users.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all active:scale-[0.98] ${
|
||||
isSelected(u.id)
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-11 h-11 rounded-2xl object-cover" />
|
||||
) : (
|
||||
<div className="w-11 h-11 rounded-2xl bg-linear-to-br from-primary to-primary-container flex items-center justify-center text-on-primary font-black text-sm shadow-inner">
|
||||
{(u.displayName || u.userName || u.username || '?')[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
{u.isOnline && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-emerald-500 rounded-full border-[3px] border-surface-container shadow-lg" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left flex-1">
|
||||
<p className="text-[14px] font-bold text-white/90 truncate">
|
||||
{u.displayName || u.userName || u.username || ''}
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-500 font-medium truncate">@{u.userName || u.username || ''}</p>
|
||||
</div>
|
||||
{mode === 'group-select' && (
|
||||
<div className={`w-6 h-6 rounded-lg border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
isSelected(u.id)
|
||||
? 'bg-primary border-primary shadow-[0_0_15px_rgba(154,203,255,0.3)]'
|
||||
: 'border-white/10'
|
||||
}`}>
|
||||
{isSelected(u.id) && <Check size={14} className="text-on-primary" strokeWidth={4} />}
|
||||
</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 || u.username || ''}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 truncate">@{u.userName || 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>
|
||||
))
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : query.trim().length >= 3 && users.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500">
|
||||
<p className="text-sm">{t('usersNotFound')}</p>
|
||||
@@ -310,46 +314,48 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
</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 || u.username || '?')[0]?.toUpperCase() || '?'}
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-[0.2em] px-4 mb-3 font-bold">{t('friends')}</p>
|
||||
<div className="space-y-1 px-1">
|
||||
{friends.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all active:scale-[0.98] ${
|
||||
isSelected(u.id)
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-11 h-11 rounded-2xl object-cover" />
|
||||
) : (
|
||||
<div className="w-11 h-11 rounded-2xl bg-linear-to-br from-primary to-primary-container flex items-center justify-center text-on-primary font-black text-sm shadow-inner">
|
||||
{(u.displayName || u.userName || u.username || '?')[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
{u.isOnline && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-emerald-500 rounded-full border-[3px] border-surface-container shadow-lg" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left flex-1">
|
||||
<p className="text-[14px] font-bold text-white/90 truncate">
|
||||
{u.displayName || u.userName || u.username || ''}
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-500 font-medium truncate">@{u.userName || u.username || ''}</p>
|
||||
</div>
|
||||
{mode === 'group-select' && (
|
||||
<div className={`w-6 h-6 rounded-lg border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
isSelected(u.id)
|
||||
? 'bg-primary border-primary shadow-[0_0_15px_rgba(154,203,255,0.3)]'
|
||||
: 'border-white/10'
|
||||
}`}>
|
||||
{isSelected(u.id) && <Check size={14} className="text-on-primary" strokeWidth={4} />}
|
||||
</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 || u.username || ''}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 truncate">@{u.userName || 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>
|
||||
))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-zinc-500">
|
||||
|
||||
@@ -95,9 +95,7 @@ export const useFriendStore = create<FriendState>((set, get) => ({
|
||||
const socket = getSocket();
|
||||
if (socket) socket.emit('friend_request', { friendId });
|
||||
|
||||
if (result.status === 'accepted') {
|
||||
await get().loadFriends();
|
||||
}
|
||||
await get().loadFriends();
|
||||
set((state) => ({
|
||||
searchResults: state.searchResults.filter(u => u.id !== friendId)
|
||||
}));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user