Новый дизайн, удаление
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
Forward,
|
||||
Bookmark,
|
||||
ArrowLeft,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
@@ -317,10 +318,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
if (!activeChat || !chat) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-surface-secondary/50 rounded-[2rem] overflow-hidden border border-white/5 shadow-2xl relative z-0 backdrop-blur-3xl group">
|
||||
<div className="flex-1 h-full flex items-center justify-center bg-surface-secondary/50 rounded-[2rem] overflow-hidden border border-white/5 shadow-2xl relative z-0 backdrop-blur-3xl group">
|
||||
{/* Slowly pulsing purple background as requested */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden transition-opacity duration-[10000ms]">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60vw] h-[60vw] max-w-[800px] max-h-[800px] bg-vortex-600/10 rounded-full blur-[120px] animate-[pulse_8s_ease-in-out_infinite]" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60vw] h-[60vw] max-w-[800px] max-h-[800px] bg-accent/10 rounded-full blur-[120px] animate-[pulse_8s_ease-in-out_infinite]" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] max-w-[500px] max-h-[500px] bg-purple-600/15 rounded-full blur-[100px] animate-[pulse_12s_ease-in-out_infinite_reverse]" />
|
||||
</div>
|
||||
|
||||
@@ -331,18 +332,18 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-28 h-28 mx-auto mb-8 rounded-[2rem] bg-gradient-to-br from-vortex-500/20 to-purple-600/20 flex items-center justify-center shadow-[0_0_60px_-15px_var(--color-accent)] ring-1 ring-white/10 backdrop-blur-2xl relative"
|
||||
className="w-28 h-28 mx-auto mb-8 rounded-[2rem] bg-gradient-to-br from-accent/20 to-purple-600/20 flex items-center justify-center shadow-[0_0_60px_-15px_var(--color-accent)] ring-1 ring-white/10 backdrop-blur-2xl relative"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-[2rem] bg-gradient-to-br from-white/[0.05] to-transparent pointer-events-none" />
|
||||
<img src="/logo.png" alt="Vortex" className="w-16 h-16 rounded-2xl object-cover shadow-2xl transform hover:scale-105 transition-transform" />
|
||||
<MessageSquare className="w-16 h-16 text-accent shadow-2xl transform hover:scale-105 transition-transform" />
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-vortex-400 via-fuchsia-400 to-indigo-400 mb-4 drop-shadow-lg tracking-tight"
|
||||
className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent via-fuchsia-400 to-indigo-400 mb-4 drop-shadow-lg tracking-tight"
|
||||
>
|
||||
Vortex Messenger
|
||||
SelfHost Messenger
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { api } from '../lib/api';
|
||||
import { useLang } from '../lib/i18n';
|
||||
@@ -10,6 +12,7 @@ interface ParticipantInfo {
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatar?: string | null;
|
||||
isSharingScreen?: boolean;
|
||||
}
|
||||
|
||||
interface PeerState {
|
||||
@@ -65,6 +68,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
const [microphones, setMicrophones] = useState<MediaDeviceInfo[]>([]);
|
||||
const [activeMicId, setActiveMicId] = useState<string>('');
|
||||
const [showMicMenu, setShowMicMenu] = useState(false);
|
||||
const [sharingUserId, setSharingUserId] = useState<string | null>(null);
|
||||
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
@@ -289,6 +293,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
}
|
||||
}
|
||||
setIsScreenSharing(false);
|
||||
const socket = getSocket();
|
||||
socket?.emit('screen_share_stopped', chatId);
|
||||
} else {
|
||||
try {
|
||||
const screenStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
@@ -328,6 +334,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
};
|
||||
|
||||
setIsScreenSharing(true);
|
||||
const socket = getSocket();
|
||||
socket?.emit('screen_share_started', chatId);
|
||||
} catch { console.error('Screen share failed'); }
|
||||
}
|
||||
}, [isScreenSharing, chatId]);
|
||||
@@ -521,6 +529,9 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
const newMap = new Map<string, ParticipantInfo>();
|
||||
for (const p of data.participants) {
|
||||
newMap.set(p.id, p);
|
||||
if (p.isSharingScreen && p.id !== useAuthStore.getState().user?.id) {
|
||||
setSharingUserId(p.id);
|
||||
}
|
||||
// Create peer connection to each existing participant (we are the initiator)
|
||||
if (!peersRef.current.has(p.id)) {
|
||||
await createPeerConnection(p.id, true);
|
||||
@@ -605,6 +616,38 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
}
|
||||
};
|
||||
|
||||
const onScreenShareStarted = (data: { userId: string }) => {
|
||||
const isMe = data.userId === useAuthStore.getState().user?.id;
|
||||
if (!isMe) {
|
||||
setSharingUserId(data.userId);
|
||||
}
|
||||
setParticipants(prev => {
|
||||
const next = new Map(prev);
|
||||
const p = next.get(data.userId);
|
||||
if (p) next.set(data.userId, { ...p, isSharingScreen: true });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onScreenShareStopped = (data: { userId: string }) => {
|
||||
const isMe = data.userId === useAuthStore.getState().user?.id;
|
||||
if (!isMe && sharingUserId === data.userId) {
|
||||
setSharingUserId(null);
|
||||
} else if (isMe) {
|
||||
// Just in case
|
||||
}
|
||||
|
||||
// If the person who stopped was the sharing user, clear it
|
||||
setSharingUserId(current => current === data.userId ? null : current);
|
||||
|
||||
setParticipants(prev => {
|
||||
const next = new Map(prev);
|
||||
const p = next.get(data.userId);
|
||||
if (p) next.set(data.userId, { ...p, isSharingScreen: false });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('group_call_participants', onParticipants);
|
||||
socket.on('group_call_user_joined', onUserJoined);
|
||||
socket.on('group_call_user_left', onUserLeft);
|
||||
@@ -613,6 +656,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
socket.on('group_ice_candidate', onIceCandidate);
|
||||
socket.on('group_call_renegotiate', onRenegotiate);
|
||||
socket.on('group_call_renegotiate_answer', onRenegotiateAnswer);
|
||||
socket.on('screen_share_started', onScreenShareStarted);
|
||||
socket.on('screen_share_stopped', onScreenShareStopped);
|
||||
|
||||
return () => {
|
||||
socket.off('group_call_participants', onParticipants);
|
||||
@@ -623,6 +668,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
socket.off('group_ice_candidate', onIceCandidate);
|
||||
socket.off('group_call_renegotiate', onRenegotiate);
|
||||
socket.off('group_call_renegotiate_answer', onRenegotiateAnswer);
|
||||
socket.off('screen_share_started', onScreenShareStarted);
|
||||
socket.off('screen_share_stopped', onScreenShareStopped);
|
||||
};
|
||||
}, [isOpen, chatId, createPeerConnection]);
|
||||
|
||||
@@ -856,6 +903,16 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screen share indicator */}
|
||||
{sharingUserId && (
|
||||
<div className="absolute top-6 left-6 z-20 flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/20 border border-blue-500/30 backdrop-blur-md">
|
||||
<Monitor size={14} className="text-blue-400 animate-pulse" />
|
||||
<span className="text-xs font-medium text-blue-100">
|
||||
{participants.get(sharingUserId)?.displayName || 'Кто-то'} транслирует экран
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-8 pb-8 pt-4 flex items-center justify-center gap-4 flex-wrap">
|
||||
{/* Mic with dropdown */}
|
||||
@@ -882,12 +939,16 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleScreenShare}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||
title={isScreenSharing ? t('stopScreenShare') : t('screenShare')}
|
||||
>
|
||||
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
|
||||
</button>
|
||||
onClick={toggleScreenShare}
|
||||
disabled={!!sharingUserId && !isScreenSharing}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${isScreenSharing
|
||||
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/40'
|
||||
: 'bg-white/10 text-white hover:bg-white/15'
|
||||
} ${!!sharingUserId && !isScreenSharing ? 'opacity-30 cursor-not-allowed' : ''}`}
|
||||
title={sharingUserId ? 'Кто-то уже транслирует экран' : ''}
|
||||
>
|
||||
{isScreenSharing ? <MonitorOff size={20} /> : <Monitor size={20} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
|
||||
|
||||
@@ -55,6 +55,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
try {
|
||||
const chat = await api.createPersonalChat(selectedUser.id);
|
||||
addChat(chat);
|
||||
import('../lib/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
onClose();
|
||||
@@ -75,14 +76,15 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
if (!groupName.trim() || selectedUsers.length === 0) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const chat = await api.createGroupChat(
|
||||
groupName.trim(),
|
||||
selectedUsers.map((u) => u.id)
|
||||
);
|
||||
addChat(chat);
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
onClose();
|
||||
const chat = await api.createGroupChat(
|
||||
groupName.trim(),
|
||||
selectedUsers.map((u) => u.id)
|
||||
);
|
||||
addChat(chat);
|
||||
import('../lib/socket').then(({ getSocket }) => getSocket()?.emit('join_chat', chat.id));
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
{ icon: Users, label: t('friends'), onClick: () => changeView('friends'), badge: friendRequests.length > 0 ? friendRequests.length : undefined },
|
||||
{ icon: Settings, label: t('settings'), onClick: () => changeView('settings') },
|
||||
{ divider: true },
|
||||
{ icon: Info, label: t('aboutApp'), subtitle: 'Vortex Messenger v1.0', onClick: () => changeView('about') },
|
||||
{ icon: Info, label: t('aboutApp'), subtitle: 'SelfHost Messenger v1.0', onClick: () => changeView('about') },
|
||||
];
|
||||
|
||||
// Slide direction for animations
|
||||
@@ -664,13 +664,13 @@ export default function SideMenu({ isOpen, onClose }: SideMenuProps) {
|
||||
</div>
|
||||
<div className="px-5 py-3">
|
||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">{t('about')}</h4>
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50">
|
||||
<Info size={18} className="text-zinc-400" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-200">Vortex Messenger</p>
|
||||
<p className="text-xs text-zinc-500">{t('version')} 1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50">
|
||||
<Info size={18} className="text-zinc-400" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-200">SelfHost Messenger</p>
|
||||
<p className="text-xs text-zinc-500">{t('version')} 1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -86,8 +86,10 @@ export default function Sidebar() {
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<img src="/logo.png" alt="Vortex" className="w-8 h-8 rounded-lg object-cover" />
|
||||
<h1 className="text-lg font-bold gradient-text truncate">Vortex</h1>
|
||||
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center text-white">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold gradient-text truncate">SelfHost</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
@@ -128,8 +130,8 @@ export default function Sidebar() {
|
||||
onClick={() => setShowCreateStory(true)}
|
||||
className="flex flex-col items-center gap-1 flex-shrink-0 group"
|
||||
>
|
||||
<div className="w-12 h-12 sm:w-14 sm:h-14 rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center group-hover:border-vortex-400 transition-colors">
|
||||
<Plus size={18} className="text-zinc-400 group-hover:text-vortex-400 transition-colors" />
|
||||
<div className="w-12 h-12 sm:w-14 sm:h-14 rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center group-hover:border-accent transition-colors">
|
||||
<Plus size={18} className="text-zinc-400 group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 truncate w-12 sm:w-14 text-center">{t('newStory')}</span>
|
||||
</button>
|
||||
@@ -145,7 +147,7 @@ export default function Sidebar() {
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-full p-[2.5px] transition-transform group-hover:scale-105 ${
|
||||
group.hasUnviewed
|
||||
? 'bg-gradient-to-tr from-vortex-400 via-purple-500 to-pink-500 shadow-lg shadow-vortex-500/25'
|
||||
? 'bg-gradient-to-tr from-accent via-purple-500 to-pink-500 shadow-lg shadow-accent/25'
|
||||
: isMine
|
||||
? 'bg-gradient-to-tr from-zinc-500 to-zinc-600'
|
||||
: 'bg-zinc-700'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp } from 'lucide-react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { api } from '../lib/api';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { getInitials, generateAvatarColor } from '../lib/utils';
|
||||
import Avatar from './Avatar';
|
||||
@@ -124,16 +125,49 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
};
|
||||
}, [storyIndex, userIndex, paused, goNext]);
|
||||
|
||||
// Keyboard
|
||||
// Keyboard and Socket
|
||||
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 }) => {
|
||||
if (!currentStory || data.storyId !== currentStory.id) return;
|
||||
|
||||
setViewOverrides(prev => ({
|
||||
...prev,
|
||||
[data.storyId]: {
|
||||
viewCount: data.viewCount,
|
||||
viewed: prev[data.storyId]?.viewed || false
|
||||
}
|
||||
}));
|
||||
|
||||
// Update viewers list if visible
|
||||
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());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [goNext, goPrev, onClose]);
|
||||
socket?.on('story_viewed', handleStoryViewed);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
socket?.off('story_viewed', handleStoryViewed);
|
||||
};
|
||||
}, [goNext, goPrev, onClose, currentStory?.id, showViewers]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!currentStory) return;
|
||||
|
||||
Reference in New Issue
Block a user