Ответы и реакции истории, публикации в профиле. Фиксы
This commit is contained in:
@@ -76,7 +76,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const chatViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const chat = chats.find((c) => c.id === activeChat);
|
||||
const chatMessages = activeChat ? messages[activeChat] || [] : [];
|
||||
const allChatMessages = activeChat ? messages[activeChat] || [] : [];
|
||||
// Filter out deleted messages to prevent layout shifts
|
||||
const chatMessages = allChatMessages.filter(m => !m.isDeleted);
|
||||
const pinnedMsg = activeChat ? pinnedMessages[activeChat] : null;
|
||||
|
||||
// Количество непрочитанных сообщений (для бейджика)
|
||||
@@ -194,7 +196,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChat || !user?.id) return;
|
||||
|
||||
|
||||
// Cleanup previous observer
|
||||
if (observerRef.current) observerRef.current.disconnect();
|
||||
sentReadIdsRef.current.clear();
|
||||
@@ -917,6 +919,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
userId={profileUserId}
|
||||
chatId={activeChat || undefined}
|
||||
onClose={() => setProfileUserId(null)}
|
||||
onGoToMessage={(msgId) => {
|
||||
const el = document.getElementById(`msg-${msgId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('bg-vortex-500/20');
|
||||
setTimeout(() => el.classList.remove('bg-vortex-500/20'), 2000);
|
||||
setProfileUserId(null);
|
||||
}
|
||||
}}
|
||||
isSelf={profileUserId === user?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -279,28 +279,8 @@ function MessageBubble({
|
||||
};
|
||||
}, [showContext]);
|
||||
|
||||
const [deletedVisible, setDeletedVisible] = useState(true);
|
||||
useEffect(() => {
|
||||
if (message.isDeleted) {
|
||||
const timer = setTimeout(() => setDeletedVisible(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [message.isDeleted]);
|
||||
|
||||
if (message.isDeleted) {
|
||||
if (!deletedVisible) return null;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 1, height: 'auto' }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className={`flex ${isMine ? 'justify-end' : 'justify-start'} mb-1`}
|
||||
>
|
||||
<div className="px-4 py-2 rounded-2xl text-sm italic text-zinc-600 bg-surface-tertiary/50">
|
||||
{t('messageDeleted')}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const media = message.media || [];
|
||||
@@ -419,18 +399,54 @@ function MessageBubble({
|
||||
{message.replyTo.sender?.displayName || message.replyTo.sender?.username}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (
|
||||
{message.replyTo.isDeleted ? (
|
||||
<p className="text-xs text-zinc-500 italic truncate">{t('messageDeleted')}</p>
|
||||
) : (
|
||||
<>
|
||||
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (
|
||||
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
|
||||
{message.replyTo.media[0].type === 'image' ? (
|
||||
<img src={message.replyTo.media[0].url} className="w-full h-full object-cover" alt="" />
|
||||
) : message.replyTo.media[0].type === 'video' ? (
|
||||
<div className="w-full h-full 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-zinc-500" /></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-zinc-400 truncate">{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story Reply Quote */}
|
||||
{message.storyId && (
|
||||
<div
|
||||
className={`mx-3 mb-1 px-3 py-1.5 rounded-lg border-l-2 ${isMine ? 'border-white/50 bg-white/10' : 'border-accent bg-accent/10'} max-w-full`}
|
||||
>
|
||||
<p className={`text-xs font-bold uppercase tracking-wider ${isMine ? 'text-white' : 'text-accent'}`}>
|
||||
{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.replyTo.media[0].type === 'image' ? (
|
||||
<img src={message.replyTo.media[0].url} className="w-full h-full object-cover" alt="" />
|
||||
) : message.replyTo.media[0].type === 'video' ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
|
||||
{message.storyMediaType === 'video' ? (
|
||||
<div className="w-full h-full relative">
|
||||
<video src={message.storyMediaUrl.startsWith('http') ? message.storyMediaUrl : `${import.meta.env.VITE_API_URL}${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={message.storyMediaUrl.startsWith('http') ? message.storyMediaUrl : `${import.meta.env.VITE_API_URL}${message.storyMediaUrl}`} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-zinc-500" /></div>
|
||||
<div className="w-full h-full flex items-center justify-center bg-vortex-500/20"><FileText size={10} className="text-vortex-400" /></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-zinc-400 truncate">{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}</p>
|
||||
<p className={`text-xs truncate ${isMine ? 'text-white/80' : 'text-zinc-400'}`}>
|
||||
{message.quote}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -442,10 +458,10 @@ function MessageBubble({
|
||||
onDoubleClick={handleReply}
|
||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${hasImage && !message.content
|
||||
? 'p-0 shadow-none border-none'
|
||||
: isMine
|
||||
? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
|
||||
: 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
|
||||
? 'p-0 shadow-none border-none'
|
||||
: isMine
|
||||
? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
|
||||
: 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
|
||||
}`}
|
||||
>
|
||||
{/* Рендер пересланного сообщения */}
|
||||
@@ -467,10 +483,10 @@ function MessageBubble({
|
||||
{(hasImage || hasVideo) && (
|
||||
<div className={`${message.content ? 'mb-2 -mx-4 -mt-2.5' : ''} bg-black/20 overflow-hidden`}>
|
||||
<div className={`grid gap-[2px] ${media.filter(m => m.type === 'image' || m.type === 'video').length >= 3
|
||||
? 'grid-cols-3'
|
||||
: media.filter(m => m.type === 'image' || m.type === 'video').length === 2
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-1'
|
||||
? 'grid-cols-3'
|
||||
: media.filter(m => m.type === 'image' || m.type === 'video').length === 2
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-1'
|
||||
}`}>
|
||||
{media
|
||||
.filter((m) => m.type === 'image' || m.type === 'video')
|
||||
|
||||
@@ -22,6 +22,7 @@ import NewChatModal from './NewChatModal';
|
||||
import UserProfile from './UserProfile';
|
||||
import SideMenu from './SideMenu';
|
||||
import StoryViewer, { CreateStoryModal } from './StoryViewer';
|
||||
import { useStoryStore } from '../stores/useStoryStore';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
@@ -32,8 +33,7 @@ export default function Sidebar() {
|
||||
const [showNewChat, setShowNewChat] = useState(false);
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [showSideMenu, setShowSideMenu] = useState(false);
|
||||
const [storyGroups, setStoryGroups] = useState<StoryGroup[]>([]);
|
||||
const [storyViewerIndex, setStoryViewerIndex] = useState<number | null>(null);
|
||||
const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore();
|
||||
const [showCreateStory, setShowCreateStory] = useState(false);
|
||||
|
||||
const loadStories = () => {
|
||||
@@ -165,7 +165,7 @@ export default function Sidebar() {
|
||||
return (
|
||||
<button
|
||||
key={group.user.id}
|
||||
onClick={() => setStoryViewerIndex(idx)}
|
||||
onClick={() => openViewer(idx)}
|
||||
className="flex flex-col items-center gap-1 flex-shrink-0 group"
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-full p-[2.5px] transition-transform group-hover:scale-105 ${
|
||||
@@ -222,11 +222,12 @@ export default function Sidebar() {
|
||||
onClose={() => setShowSideMenu(false)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{storyViewerIndex !== null && storyGroups.length > 0 && (
|
||||
{viewerIndex !== null && storyGroups.length > 0 && (
|
||||
<StoryViewer
|
||||
stories={storyGroups}
|
||||
initialUserIndex={storyViewerIndex}
|
||||
onClose={() => { setStoryViewerIndex(null); loadStories(); }}
|
||||
initialUserIndex={viewerIndex}
|
||||
initialStoryIndex={viewerStoryIndex}
|
||||
onClose={() => { closeViewer(); loadStories(); }}
|
||||
onRefresh={loadStories}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp } from 'lucide-react';
|
||||
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp, Volume2, VolumeX, MessageCircle, Smile } 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';
|
||||
import { StoryGroup } from '../lib/types';
|
||||
|
||||
@@ -17,14 +16,17 @@ const STORY_BG_COLORS = [
|
||||
'#3b82f6', '#1e1e2e',
|
||||
];
|
||||
|
||||
const STORY_EMOJIS = ['❤️', '🔥', '😂', '😮', '😢', '👏', '🎉', '💪'];
|
||||
|
||||
interface StoryViewerProps {
|
||||
stories: StoryGroup[];
|
||||
initialUserIndex: number;
|
||||
initialStoryIndex?: number;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function StoryViewer({ stories, initialUserIndex, onClose, onRefresh }: StoryViewerProps) {
|
||||
export default function StoryViewer({ stories, initialUserIndex, initialStoryIndex, onClose, onRefresh }: StoryViewerProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const [userIndex, setUserIndex] = useState(initialUserIndex);
|
||||
@@ -32,29 +34,76 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const viewedRef = useRef<Set<string>>(new Set()); // track viewed in this session
|
||||
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; // 5 seconds per story
|
||||
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];
|
||||
// Merge prop data with local overrides to avoid mutating props
|
||||
const currentStory = rawStory ? { ...rawStory, ...viewOverrides[rawStory.id] } : null;
|
||||
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
|
||||
useEffect(() => {
|
||||
if (showReactions || showReplyInput || showViewers) {
|
||||
setPaused(true);
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
} else {
|
||||
setPaused(false);
|
||||
if (videoRef.current && videoRef.current.paused && isVideo) {
|
||||
videoRef.current.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
}, [showReactions, showReplyInput, showViewers, 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]);
|
||||
|
||||
// Reset when viewer opens with different user
|
||||
useEffect(() => {
|
||||
setUserIndex(initialUserIndex);
|
||||
setStoryIndex(0);
|
||||
setStoryIndex(initialStoryIndex || 0);
|
||||
setProgress(0);
|
||||
setPaused(false);
|
||||
viewedRef.current.clear();
|
||||
setViewOverrides({});
|
||||
}, [initialUserIndex]);
|
||||
}, [initialUserIndex, initialStoryIndex]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (!currentUser) return;
|
||||
@@ -85,15 +134,18 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
const canGoPrev = storyIndex > 0 || userIndex > 0;
|
||||
const canGoNext = (currentUser && storyIndex < currentUser.stories.length - 1) || userIndex < stories.length - 1;
|
||||
|
||||
// Mark viewed
|
||||
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;
|
||||
|
||||
api.viewStory(storyId).then(() => {
|
||||
// console.log('[StoryViewer] viewStory success, updating count to', viewCount + 1);
|
||||
setViewOverrides(prev => ({
|
||||
...prev,
|
||||
[storyId]: {
|
||||
@@ -101,14 +153,17 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
viewed: true,
|
||||
},
|
||||
}));
|
||||
}).catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}).catch(e => {
|
||||
console.error('[StoryViewer] viewStory error:', e);
|
||||
});
|
||||
}, [currentStory?.id, currentUser?.user?.id, user?.id]);
|
||||
|
||||
// Progress timer - use a key to force restart
|
||||
useEffect(() => {
|
||||
setProgress(0);
|
||||
}, [storyIndex, userIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || !currentStory) return;
|
||||
setProgress(0);
|
||||
const step = (TICK / STORY_DURATION) * 100;
|
||||
timerRef.current = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
@@ -125,7 +180,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
};
|
||||
}, [storyIndex, userIndex, paused, goNext]);
|
||||
|
||||
// Keyboard and Socket
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
@@ -134,9 +188,13 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
};
|
||||
|
||||
const socket = getSocket();
|
||||
const handleStoryViewed = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number }) => {
|
||||
|
||||
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]: {
|
||||
@@ -145,7 +203,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
}
|
||||
}));
|
||||
|
||||
// Update viewers list if visible
|
||||
if (showViewers) {
|
||||
setViewers(prev => {
|
||||
if (prev.some(v => v.userId === data.userId)) return prev;
|
||||
@@ -160,12 +217,30 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
@@ -174,16 +249,12 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
const storyId = currentStory.id;
|
||||
try {
|
||||
await api.deleteStory(storyId);
|
||||
|
||||
// Update local state immediately to avoid black screen
|
||||
|
||||
if (currentUser.stories.length > 1) {
|
||||
// Just move to next if there are more stories for this user
|
||||
if (storyIndex >= currentUser.stories.length - 1) {
|
||||
setStoryIndex(s => s - 1);
|
||||
}
|
||||
// Props will refresh and re-render correctly
|
||||
} else {
|
||||
// Last story for this user, move to next user or close
|
||||
if (userIndex < stories.length - 1) {
|
||||
setUserIndex(u => u + 1);
|
||||
setStoryIndex(0);
|
||||
@@ -191,13 +262,56 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
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 api.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 api.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;
|
||||
@@ -224,27 +338,19 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Story container */}
|
||||
<div
|
||||
className="relative w-full max-w-[420px] h-full max-h-[85vh] rounded-2xl overflow-hidden select-none"
|
||||
onMouseDown={() => setPaused(true)}
|
||||
onMouseUp={() => setPaused(false)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onTouchStart={() => setPaused(true)}
|
||||
onTouchEnd={() => setPaused(false)}
|
||||
>
|
||||
{/* Story content */}
|
||||
{currentStory.type === 'video' || (currentStory.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm'))) ? (
|
||||
{isVideo ? (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={currentStory.mediaUrl?.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
||||
className="w-full h-full object-contain"
|
||||
autoPlay
|
||||
muted
|
||||
muted={isMuted}
|
||||
playsInline
|
||||
onEnded={goNext}
|
||||
onPlay={() => setPaused(false)}
|
||||
onPause={() => setPaused(true)}
|
||||
/>
|
||||
</div>
|
||||
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
|
||||
@@ -268,7 +374,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bars */}
|
||||
<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">
|
||||
@@ -282,7 +387,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="absolute top-4 left-0 right-0 flex items-center gap-3 px-4 pt-2 z-10">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
@@ -331,14 +435,12 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Left/Right click zones */}
|
||||
<div className="absolute inset-0 flex z-[5]">
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={goPrev} />
|
||||
<div className="w-1/3 h-full" />
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={goNext} />
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{canGoPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
@@ -356,7 +458,91 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Viewers panel */}
|
||||
{/* 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
|
||||
@@ -411,7 +597,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
);
|
||||
}
|
||||
|
||||
// Story creation modal
|
||||
interface CreateStoryModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
@@ -432,8 +617,8 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
if (!file) return;
|
||||
setImageFile(file);
|
||||
if (file.type.startsWith('video/')) {
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
setMode('image'); // We use 'image' mode for both media types for now or we can rename it to 'media'
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
setMode('image');
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImagePreview(reader.result as string);
|
||||
@@ -491,7 +676,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<div className="flex border-b border-white/10">
|
||||
<button
|
||||
onClick={() => setMode('text')}
|
||||
@@ -518,7 +702,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<div className="p-4">
|
||||
{mode === 'text' ? (
|
||||
<>
|
||||
{/* Preview */}
|
||||
<div
|
||||
className="w-full h-48 rounded-xl flex items-center justify-center p-4 mb-4 transition-colors"
|
||||
style={{ background: bgColor }}
|
||||
@@ -536,7 +719,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
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-vortex-500/50"
|
||||
/>
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{STORY_BG_COLORS.map(c => (
|
||||
<button
|
||||
|
||||
@@ -4,20 +4,22 @@ import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileTex
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { User, Message, FriendshipStatus } from '../lib/types';
|
||||
import { User, Message, FriendshipStatus, StoryGroup } from '../lib/types';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { useStoryStore } from '../stores/useStoryStore';
|
||||
|
||||
interface UserProfileProps {
|
||||
userId: string;
|
||||
chatId?: string;
|
||||
onClose: () => void;
|
||||
onGoToMessage?: (messageId: string) => void;
|
||||
isSelf?: boolean;
|
||||
}
|
||||
|
||||
type MediaTab = 'media' | 'files' | 'links';
|
||||
type MediaTab = 'stories' | 'media' | 'files' | 'links';
|
||||
|
||||
export default function UserProfile({ userId, chatId, onClose, isSelf }: UserProfileProps) {
|
||||
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
|
||||
const { user: authUser } = useAuthStore();
|
||||
const { t, lang } = useLang();
|
||||
const [profile, setProfile] = useState<User | null>(null);
|
||||
@@ -29,8 +31,10 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||||
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [userStories, setUserStories] = useState<StoryGroup | null>(null);
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<MediaTab>>(new Set());
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const { openViewer } = useStoryStore();
|
||||
|
||||
// Friend state
|
||||
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
|
||||
@@ -43,6 +47,12 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, scale: 1 });
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
|
||||
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||
...m,
|
||||
url: m.url.startsWith('http') ? m.url : `${import.meta.env.VITE_API_URL}${m.url}`,
|
||||
messageId: msg.id
|
||||
})));
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
if (!isSelf) {
|
||||
@@ -52,6 +62,21 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
|
||||
// Load shared media/files/links when tab changes
|
||||
const loadTabData = useCallback(async (tab: MediaTab) => {
|
||||
if (tab === 'stories') {
|
||||
if (loadedTabs.has(tab)) return;
|
||||
setTabLoading(true);
|
||||
try {
|
||||
const data = await api.getUserStories(userId);
|
||||
setUserStories(data);
|
||||
setLoadedTabs(prev => new Set(prev).add(tab));
|
||||
} catch (e) {
|
||||
console.error('Failed to load user stories', e);
|
||||
} finally {
|
||||
setTabLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chatId || loadedTabs.has(tab)) return;
|
||||
setTabLoading(true);
|
||||
try {
|
||||
@@ -65,7 +90,7 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
} finally {
|
||||
setTabLoading(false);
|
||||
}
|
||||
}, [chatId, loadedTabs]);
|
||||
}, [chatId, userId, loadedTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTabData(activeTab);
|
||||
@@ -181,9 +206,10 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
.toUpperCase();
|
||||
|
||||
const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'media', label: t('mediaTab'), icon: ImageIcon },
|
||||
{ key: 'files', label: t('filesTab'), icon: FileText },
|
||||
{ key: 'links', label: t('linksTab'), icon: LinkIcon },
|
||||
{ key: 'stories', label: (t('storiesTab') || 'Stories') as string, icon: ImageIcon },
|
||||
{ key: 'media', label: t('mediaTab') as string, icon: ImageIcon },
|
||||
{ key: 'files', label: t('filesTab') as string, icon: FileText },
|
||||
{ key: 'links', label: t('linksTab') as string, icon: LinkIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -200,13 +226,13 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300, mass: 0.8 }}
|
||||
className="fixed right-3 top-3 bottom-3 w-[360px] max-w-[calc(100%-24px)] bg-surface-secondary/80 backdrop-blur-2xl shadow-[0_0_120px_rgba(0,0,0,0.6)] border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden"
|
||||
className="fixed right-3 top-3 bottom-3 w-[500px] max-w-[calc(100%-24px)] bg-surface-secondary/80 backdrop-blur-2xl shadow-[0_0_120px_rgba(0,0,0,0.6)] border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/5 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
|
||||
<h2 className="text-xl font-bold tracking-tight text-white drop-shadow-sm relative z-10">
|
||||
{isSelf ? t('myProfile') : t('profileTitle')}
|
||||
{(isSelf ? t('myProfile') : t('profileTitle')) as string}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -432,42 +458,94 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : activeTab === 'media' ? (
|
||||
sharedMedia.length > 0 ? (
|
||||
) : activeTab === 'stories' ? (
|
||||
userStories && userStories.stories.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{(() => {
|
||||
const allMedia = sharedMedia.flatMap((msg) => (msg.media || []));
|
||||
return allMedia.map((m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
|
||||
>
|
||||
{m.type === 'video' ? (
|
||||
<>
|
||||
<img
|
||||
src={m.thumbnail || m.url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<Play size={24} className="text-white fill-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={m.url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
)}
|
||||
{userStories.stories.map((story, idx) => (
|
||||
<div
|
||||
key={story.id}
|
||||
onClick={() => {
|
||||
const group: StoryGroup = {
|
||||
user: profile!,
|
||||
stories: userStories.stories,
|
||||
hasUnviewed: false
|
||||
};
|
||||
openViewer(0, idx, [group]);
|
||||
}}
|
||||
className="relative aspect-[9/16] bg-zinc-900 overflow-hidden group border border-white/5 rounded-md cursor-pointer"
|
||||
>
|
||||
{story.type === 'video' ? (
|
||||
<div className="w-full h-full relative">
|
||||
{story.mediaUrl && <video src={story.mediaUrl.startsWith('http') ? story.mediaUrl : `${import.meta.env.VITE_API_URL}${story.mediaUrl}`} className="w-full h-full object-cover opacity-60" />}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<Play size={20} className="text-white fill-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : story.type === 'image' ? (
|
||||
<img
|
||||
src={story.mediaUrl?.startsWith('http') ? story.mediaUrl : `${import.meta.env.VITE_API_URL}${story.mediaUrl}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center p-2 text-center overflow-hidden" style={{ background: story.bgColor || 'var(--vortex-500)' }}>
|
||||
<p className="text-[10px] text-white line-clamp-4 font-bold">{story.content}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 bg-black/50 backdrop-blur-sm px-1 rounded flex items-center gap-0.5">
|
||||
<Clock size={8} className="text-zinc-300" />
|
||||
<span className="text-[8px] text-zinc-300">{new Date(story.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos')}</p>
|
||||
<p className="text-xs text-zinc-600 italic">{(t('noStories') || 'No stories yet') as string}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'media' ? (
|
||||
allMedia.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{allMedia.map((m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
|
||||
>
|
||||
{m.type === 'video' ? (
|
||||
<>
|
||||
<img
|
||||
src={m.thumbnail ? (m.thumbnail.startsWith('http') ? m.thumbnail : `${import.meta.env.VITE_API_URL}${m.thumbnail}`) : m.url}
|
||||
alt=""
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 pointer-events-none">
|
||||
<Play size={24} className="text-white fill-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={m.url}
|
||||
alt=""
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
)}
|
||||
{/* Navigation button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||||
className="absolute bottom-1 right-1 w-6 h-6 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={(t('goToMessage') || 'Go to message') as string}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos') as string}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'files' ? (
|
||||
@@ -475,38 +553,46 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
<div className="divide-y divide-border">
|
||||
{sharedFiles.flatMap((msg) =>
|
||||
(msg.media || []).map((m) => (
|
||||
<a
|
||||
key={m.id}
|
||||
href={m.url}
|
||||
download={m.filename || 'file'}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors group/file"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-vortex-500/20 flex items-center justify-center flex-shrink-0 border border-vortex-500/30 group-hover/file:scale-105 transition-transform">
|
||||
<FileText size={18} className="text-vortex-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{m.filename || 'file'}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}
|
||||
{msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Download size={16} className="text-zinc-500 flex-shrink-0" />
|
||||
</a>
|
||||
<div key={m.id} className="relative group/file">
|
||||
<a
|
||||
href={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-10 h-10 rounded-xl bg-vortex-500/20 flex items-center justify-center flex-shrink-0 border border-vortex-500/30 group-hover/file:scale-105 transition-transform">
|
||||
<FileText size={18} className="text-vortex-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{m.filename || 'file'}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}
|
||||
{msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Download size={16} className="text-zinc-500 flex-shrink-0" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
className="absolute right-12 top-1/2 -translate-y-1/2 w-8 h-8 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-500 opacity-0 group-hover/file:opacity-100 transition-opacity"
|
||||
title={(t('goToMessage') || 'Go to message') as string}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedFiles')}</p>
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedFiles') as string}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
sharedLinks.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{sharedLinks.map((msg) => (
|
||||
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors">
|
||||
<p className="text-xs text-zinc-500 mb-1.5 font-medium">
|
||||
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors relative group/link">
|
||||
<p className="text-xs text-zinc-500 mb-1.5 font-medium pr-8">
|
||||
{msg.sender?.displayName || msg.sender?.username} · {new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US')}
|
||||
</p>
|
||||
{(msg.links || []).map((link: string, i: number) => (
|
||||
@@ -524,12 +610,19 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
{msg.content && (
|
||||
<p className="text-xs text-zinc-400 mt-1 line-clamp-2">{msg.content}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
className="absolute right-2 top-2 w-8 h-8 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-500 opacity-0 group-hover/link:opacity-100 transition-opacity"
|
||||
title={(t('goToMessage') || 'Go to message') as string}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedLinks')}</p>
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedLinks') as string}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -547,7 +640,7 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={sharedMedia.flatMap((msg) => (msg.media || []).map((m) => ({ url: m.url, type: m.type })))}
|
||||
images={allMedia.map((m) => ({ url: m.url, type: m.type }))}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
|
||||
@@ -242,6 +242,10 @@ class ApiClient {
|
||||
return this.request<StoryGroup[]>('/stories');
|
||||
}
|
||||
|
||||
async getUserStories(userId: string) {
|
||||
return this.request<StoryGroup>(`/stories/user/${userId}`);
|
||||
}
|
||||
|
||||
async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
|
||||
return this.request<{ id: string }>('/stories', {
|
||||
method: 'POST',
|
||||
@@ -277,6 +281,31 @@ class ApiClient {
|
||||
return this.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
|
||||
}
|
||||
|
||||
async addStoryReaction(storyId: string, emoji: string) {
|
||||
return this.request<{ message: string }>(`/stories/${storyId}/reaction`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ emoji }),
|
||||
});
|
||||
}
|
||||
|
||||
async removeStoryReaction(storyId: string, emoji: string) {
|
||||
return this.request<{ message: string }>(`/stories/${storyId}/reaction`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ emoji }),
|
||||
});
|
||||
}
|
||||
|
||||
async addStoryReply(storyId: string, content: string) {
|
||||
return this.request<{ message: string }>(`/stories/${storyId}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async getStoryReplies(storyId: string) {
|
||||
return this.request<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
|
||||
}
|
||||
|
||||
// Favorites chat
|
||||
async getOrCreateFavorites() {
|
||||
return this.request<Chat>('/chats/favorites', { method: 'POST' });
|
||||
|
||||
@@ -179,6 +179,10 @@ const translations = {
|
||||
sharedFiles: 'Общие файлы будут здесь',
|
||||
sharedLinks: 'Общие ссылки будут здесь',
|
||||
profileNotFound: 'Профиль не найден',
|
||||
storiesTab: 'Публикации',
|
||||
noStories: 'Публикаций пока нет',
|
||||
goToMessage: 'Перейти к сообщению',
|
||||
story: 'История',
|
||||
// Typing
|
||||
typingText: 'печатает',
|
||||
// Emoji
|
||||
@@ -247,6 +251,8 @@ const translations = {
|
||||
// Story viewers
|
||||
storyViewers: 'Кто просмотрел',
|
||||
noViewers: 'Пока никто не посмотрел',
|
||||
replyToStory: 'Ответить на историю...',
|
||||
send: 'Отправить',
|
||||
// Favorites
|
||||
favorites: 'Избранное',
|
||||
favoritesDescription: 'Сохраняйте важные сообщения здесь',
|
||||
@@ -435,6 +441,10 @@ const translations = {
|
||||
sharedFiles: 'Shared files will appear here',
|
||||
sharedLinks: 'Shared links will appear here',
|
||||
profileNotFound: 'Profile not found',
|
||||
storiesTab: 'Stories',
|
||||
noStories: 'No stories yet',
|
||||
goToMessage: 'Go to message',
|
||||
story: 'Story',
|
||||
typingText: 'typing',
|
||||
emojiFrequent: 'Frequent',
|
||||
emojiGestures: 'Gestures',
|
||||
@@ -492,6 +502,8 @@ const translations = {
|
||||
minCharsHint: 'Enter at least 3 characters after @',
|
||||
storyViewers: 'Who viewed',
|
||||
noViewers: 'No one viewed yet',
|
||||
replyToStory: 'Reply to story...',
|
||||
send: 'Send',
|
||||
favorites: 'Favorites',
|
||||
favoritesDescription: 'Save important messages here',
|
||||
privacy: 'Privacy',
|
||||
|
||||
@@ -27,7 +27,7 @@ export function connectSocket(token: string): SocketCompat {
|
||||
// Обертка для совместимости с Socket.io API
|
||||
socketWrapper = {
|
||||
on: (event: string, callback: (...args: any[]) => void) => {
|
||||
console.log(`[SignalR] Registering handler for: ${event}`);
|
||||
// console.log(`[SignalR] Registering handler for: ${event}`);
|
||||
connection?.on(event, callback);
|
||||
},
|
||||
off: (event: string, callback?: (...args: any[]) => void) => {
|
||||
@@ -39,15 +39,15 @@ export function connectSocket(token: string): SocketCompat {
|
||||
},
|
||||
emit: (event: string, ...args: any[]) => {
|
||||
const state = connection?.state;
|
||||
console.log(`[SignalR] emit('${event}') called, connection state: ${state}`);
|
||||
// console.log(`[SignalR] emit('${event}') called, connection state: ${state}`);
|
||||
if (connection?.state === 'Connected') {
|
||||
// В SignalR invoke возвращает Promise, но Socket.io emit - нет.
|
||||
// Мы просто запускаем и логируем ошибки.
|
||||
connection.invoke(event, ...args)
|
||||
.then(() => console.log(`[SignalR] invoke('${event}') completed successfully`))
|
||||
// .then(() => console.log(`[SignalR] invoke('${event}') completed successfully`))
|
||||
.catch(err => console.error(`SignalR emit error (${event}):`, err));
|
||||
} else {
|
||||
console.warn(`SignalR emit skipped (${event}): connection state is ${state}`);
|
||||
// console.warn(`SignalR emit skipped (${event}): connection state is ${state}`);
|
||||
}
|
||||
},
|
||||
disconnect: () => {
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface Message {
|
||||
replyToId: string | null;
|
||||
quote?: string | null;
|
||||
forwardedFromId?: string | null;
|
||||
storyId?: string | null;
|
||||
storyMediaUrl?: string | null;
|
||||
storyMediaType?: string | null;
|
||||
isEdited: boolean;
|
||||
isDeleted: boolean;
|
||||
scheduledAt?: string | null;
|
||||
@@ -76,6 +79,7 @@ export interface Message {
|
||||
replyTo?: {
|
||||
id: string;
|
||||
content: string | null;
|
||||
isDeleted?: boolean;
|
||||
quote?: string | null;
|
||||
media?: MediaItem[];
|
||||
sender: { id: string; username: string; displayName: string };
|
||||
@@ -128,6 +132,8 @@ export interface Story {
|
||||
expiresAt: string;
|
||||
viewCount: number;
|
||||
viewed: boolean;
|
||||
reactions?: Array<{ id: string; userId: string; emoji: string; createdAt: string }>;
|
||||
replyCount?: number;
|
||||
}
|
||||
|
||||
export interface StoryViewer {
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function ChatPage() {
|
||||
}
|
||||
addMessage(message);
|
||||
// Play notification sound for messages from others
|
||||
if (message.senderId !== user?.id && !isChatMuted(message.chatId)) {
|
||||
if (message.senderId !== user?.id && !message.storyId && !isChatMuted(message.chatId)) {
|
||||
playNotificationSound();
|
||||
}
|
||||
});
|
||||
@@ -219,6 +219,31 @@ export default function ChatPage() {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('new_message');
|
||||
socket.off('scheduled_delivered');
|
||||
@@ -237,6 +262,9 @@ export default function ChatPage() {
|
||||
socket.off('message_pinned');
|
||||
socket.off('message_unpinned');
|
||||
socket.off('call_incoming');
|
||||
socket.off('story_viewed');
|
||||
socket.off('story_reply');
|
||||
socket.off('story_reaction');
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
return {
|
||||
...chat,
|
||||
messages: [message],
|
||||
unreadCount: chat.id === state.activeChat ? chat.unreadCount : chat.unreadCount + 1,
|
||||
unreadCount: (chat.id === state.activeChat || message.senderId === userId || message.storyId) ? chat.unreadCount : chat.unreadCount + 1,
|
||||
};
|
||||
}
|
||||
return chat;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import { StoryGroup } from '../lib/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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user