Рабочий чат
This commit is contained in:
@@ -7,12 +7,13 @@ 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, ChatMember } from '../../../core/domain/types';
|
||||
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
|
||||
import { Send, Check, Phone, PhoneOff, Users } from 'lucide-react';
|
||||
import Sidebar from '../../../core/presentation/layouts/Sidebar';
|
||||
import GlobalNavBar from '../../../core/presentation/layouts/GlobalNavBar';
|
||||
import ChatView from './components/ChatView';
|
||||
import CallModal from '../../calls/presentation/components/CallModal';
|
||||
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
|
||||
import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar';
|
||||
|
||||
export default function ChatPage() {
|
||||
const {
|
||||
@@ -57,6 +58,7 @@ export default function ChatPage() {
|
||||
const groupCallOpenRef = useRef(false);
|
||||
const groupCallChatIdRef = useRef('');
|
||||
|
||||
const [activeTab, setActiveTab] = useState('chats');
|
||||
const { t } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -365,22 +367,58 @@ export default function ChatPage() {
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-screen w-screen flex bg-surface-dim overflow-hidden antialiased font-body selection:bg-primary/30"
|
||||
>
|
||||
<GlobalNavBar activeTab="chats" onTabChange={() => {}} />
|
||||
<GlobalNavBar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<main className="ml-20 flex-1 flex flex-row relative h-full">
|
||||
{/* Chat List (Sidebar) */}
|
||||
<div
|
||||
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low tonal-transition-no-border h-full`}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
{activeTab === 'chats' ? (
|
||||
<>
|
||||
{/* Chat List (Sidebar) */}
|
||||
<div
|
||||
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Selected Chat View (Main Area) */}
|
||||
<div
|
||||
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest m-4 rounded-3xl overflow-hidden relative group slide-on-ice`}
|
||||
>
|
||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||
</div>
|
||||
{/* Selected Chat View (Main Area) */}
|
||||
<div
|
||||
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest m-4 rounded-3xl overflow-hidden relative group slide-on-ice`}
|
||||
>
|
||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||
</div>
|
||||
</>
|
||||
) : activeTab === 'contacts' ? (
|
||||
<div className="flex-1 flex flex-row h-full overflow-hidden">
|
||||
{/* Contacts Sidebar List */}
|
||||
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
|
||||
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
|
||||
</div>
|
||||
|
||||
{/* Right side placeholder / Profile detail */}
|
||||
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base m-4 rounded-3xl overflow-hidden border border-white/5 relative slide-on-ice">
|
||||
<div className="flex flex-col items-center gap-6 max-w-sm text-center">
|
||||
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
|
||||
<Users size={48} className="knot-logo-spin opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
|
||||
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
|
||||
{t('selectContactToChat')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('chats')}
|
||||
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
|
||||
>
|
||||
{t('backToChats')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-zinc-500">Coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<CallModal
|
||||
key={callSessionId}
|
||||
|
||||
@@ -193,30 +193,34 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
{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"
|
||||
className="fixed z-[9999] min-w-[200px] py-1.5 rounded-[1.25rem] bg-[#1a1a1a] border border-white/10 shadow-[0_20px_50px_rgba(0,0,0,0.5)] 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" />
|
||||
{!isFavorites && (
|
||||
<>
|
||||
<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')}
|
||||
{isFavorites ? t('clearHistory') : t('deleteChat')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
message={t('deleteChatConfirm')}
|
||||
message={isFavorites ? t('clearHistoryConfirm') : t('deleteChatConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
|
||||
@@ -516,7 +516,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
<div className="relative" ref={deleteMenuRef}>
|
||||
<button
|
||||
disabled={selectedMessages.size === 0}
|
||||
onClick={() => setShowDeleteMenu(!showDeleteMenu)}
|
||||
onClick={() => isFavorites ? handleBulkDelete(false) : setShowDeleteMenu(!showDeleteMenu)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-500/90 text-white font-medium rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
@@ -529,7 +529,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-surface-secondary/95 backdrop-blur-2xl shadow-2xl z-50 py-1.5 ring-1 ring-border/50 overflow-hidden"
|
||||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-50 py-1.5 ring-1 ring-white/10 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleBulkDelete(false)}
|
||||
@@ -690,7 +690,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl glass-strong shadow-2xl z-50 py-1.5 ring-1 ring-border/50 backdrop-blur-2xl"
|
||||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-50 py-1.5 ring-1 ring-white/10"
|
||||
>
|
||||
<button
|
||||
onClick={openSearch}
|
||||
@@ -699,7 +699,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
<Search size={16} />
|
||||
{t('searchMessages')}
|
||||
</button>
|
||||
{chat.type === 'personal' && otherMember && (
|
||||
{!isFavorites && chat.type === 'personal' && otherMember && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
@@ -711,19 +711,21 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{t('userProfile')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeChat) {
|
||||
const nowMuted = toggleMuteChat(activeChat);
|
||||
setMuted(nowMuted);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{muted ? <Bell size={16} /> : <BellOff size={16} />}
|
||||
{muted ? t('enableSound') : t('disableSound')}
|
||||
</button>
|
||||
{chat.type === 'group' && (
|
||||
{!isFavorites && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeChat) {
|
||||
const nowMuted = toggleMuteChat(activeChat);
|
||||
setMuted(nowMuted);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{muted ? <Bell size={16} /> : <BellOff size={16} />}
|
||||
{muted ? t('enableSound') : t('disableSound')}
|
||||
</button>
|
||||
)}
|
||||
{!isFavorites && chat.type === 'group' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
@@ -735,13 +737,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{t('groupSettings')}
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-border my-1" />
|
||||
<div className="border-t border-white/5 my-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
if (activeChat) {
|
||||
setConfirmAction({
|
||||
message: t('clearChatConfirm'),
|
||||
message: isFavorites ? t('clearHistoryConfirm') : t('clearChatConfirm'),
|
||||
action: async () => {
|
||||
try {
|
||||
await ChatApi.clearChat(activeChat);
|
||||
@@ -756,30 +758,32 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
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"
|
||||
>
|
||||
<Eraser size={16} />
|
||||
{t('clearChat')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
if (activeChat) {
|
||||
setConfirmAction({
|
||||
message: t('deleteChatConfirm'),
|
||||
action: async () => {
|
||||
try {
|
||||
await ChatApi.deleteChat(activeChat);
|
||||
useChatStore.getState().removeChat(activeChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
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')}
|
||||
{isFavorites ? t('clearHistory') : t('clearChat')}
|
||||
</button>
|
||||
{!isFavorites && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
if (activeChat) {
|
||||
setConfirmAction({
|
||||
message: t('deleteChatConfirm'),
|
||||
action: async () => {
|
||||
try {
|
||||
await ChatApi.deleteChat(activeChat);
|
||||
useChatStore.getState().removeChat(activeChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search } from 'lucide-react';
|
||||
@@ -32,14 +33,14 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[99999] 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"
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-md"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
@@ -48,31 +49,31 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
|
||||
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"
|
||||
className="relative w-full max-w-md bg-[#1a1a1a] rounded-[2rem] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10"
|
||||
>
|
||||
<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>
|
||||
<div className="p-5 flex items-center justify-between border-b border-white/5">
|
||||
<h2 className="text-xl font-bold font-headline 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"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl 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} />
|
||||
<div className="p-5">
|
||||
<div className="relative mb-5">
|
||||
<Search className="absolute left-4 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"
|
||||
className="w-full bg-black/40 border border-white/10 rounded-2xl py-3 pl-12 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto space-y-1 pr-2 custom-scrollbar">
|
||||
<div className="max-h-96 overflow-y-auto space-y-1.5 pr-2 custom-scrollbar">
|
||||
{filteredChats.map((chat) => {
|
||||
const otherMember = chat.members.find((m) => m.userId !== user?.id);
|
||||
const chatName =
|
||||
@@ -88,19 +89,33 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
|
||||
<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"
|
||||
className="w-full flex items-center gap-4 p-3 rounded-2xl hover:bg-white/5 transition-all text-left group"
|
||||
>
|
||||
<Avatar src={chatAvatar} name={chatName} size="md" />
|
||||
<span className="text-white font-medium flex-1 truncate">{chatName}</span>
|
||||
<div className="relative group-hover:scale-105 transition-transform duration-300">
|
||||
{chat.type === 'favorites' ? (
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary to-primary-container flex items-center justify-center shadow-lg shadow-primary/10 border-2 border-outline-variant/10">
|
||||
<span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar src={chatAvatar} name={chatName} size="lg" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-white font-bold truncate group-hover:text-primary transition-colors">{chatName}</div>
|
||||
<div className="text-[11px] text-zinc-500 font-bold uppercase tracking-widest mt-0.5">
|
||||
{chat.type === 'favorites' ? t('favoritesDescription') : chat.type === 'personal' ? t('chat') : `${chat.members.length} ${t('members')}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredChats.length === 0 && (
|
||||
<p className="text-center text-zinc-500 py-4 text-sm">{t('nothingFound')}</p>
|
||||
<p className="text-center text-zinc-500 py-8 text-sm">{t('nothingFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ function MessageBubble({
|
||||
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
|
||||
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
|
||||
const [lightboxData, setLightboxData] = useState<{ index: number } | null>(null);
|
||||
const activeChatId = useChatStore(s => s.activeChat);
|
||||
const activeChat = useChatStore(s => s.chats.find(c => c.id === activeChatId));
|
||||
const isFavorites = activeChat?.type === 'favorites';
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [audioProgress, setAudioProgress] = useState(0);
|
||||
const [audioDuration, setAudioDuration] = useState(0);
|
||||
@@ -862,7 +865,7 @@ function MessageBubble({
|
||||
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] bg-[#1a1a1a]/95 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10"
|
||||
className="fixed z-[9999] w-52 rounded-[1.25rem] bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10"
|
||||
style={{ left: contextPos.x, top: contextPos.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
@@ -972,7 +975,7 @@ function MessageBubble({
|
||||
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={() => setDeleteMenuMode(true)}
|
||||
onClick={() => isFavorites ? handleDeleteForMe() : 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} />
|
||||
|
||||
Reference in New Issue
Block a user