diff --git a/backend/src/Modules/Relations/Application/Abstractions/FriendshipRepository.cs b/backend/src/Modules/Relations/Application/Abstractions/FriendshipRepository.cs index c28d180..51b0f01 100644 --- a/backend/src/Modules/Relations/Application/Abstractions/FriendshipRepository.cs +++ b/backend/src/Modules/Relations/Application/Abstractions/FriendshipRepository.cs @@ -22,15 +22,15 @@ internal sealed class FriendshipRepository : IFriendshipRepository public async Task> GetAcceptedFriendshipsAsync(Guid userId, CancellationToken ct = default) { - var friendships = await _context.Set() - .Where(f => f.Status == Knot.Contracts.Relations.Domain.FriendshipStatus.Accepted && (f.UserId == userId || f.FriendId == userId)) + var contacts = await _context.Contacts + .Where(f => f.Status == ContactStatus.Accepted && (f.UserId == userId || f.ContactId == userId)) .ToListAsync(ct); - return friendships.Select(f => new Knot.Contracts.Relations.Domain.Friendship( + return contacts.Select(f => new Knot.Contracts.Relations.Domain.Friendship( f.Id, f.UserId, - f.FriendId, - f.Status, + f.ContactId, + (Knot.Contracts.Relations.Domain.FriendshipStatus)f.Status, f.CreatedAt )).ToList(); } diff --git a/backend/src/Modules/Stories/DependencyInjection.cs b/backend/src/Modules/Stories/DependencyInjection.cs index 78ceffa..8d4fe1e 100644 --- a/backend/src/Modules/Stories/DependencyInjection.cs +++ b/backend/src/Modules/Stories/DependencyInjection.cs @@ -16,6 +16,7 @@ public static class DependencyInjection { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); var connectionString = configuration.GetConnectionString("DefaultConnection"); diff --git a/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs b/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs index fb68d06..19839f1 100644 --- a/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs +++ b/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs @@ -1,3 +1,7 @@ +using Knot.Modules.Stories.Application.Stories.Commands.AddStoryReaction; +using Knot.Modules.Stories.Application.Stories.Commands.AddStoryReply; +using Knot.Modules.Stories.Application.Stories.Commands.RemoveStoryReaction; +using Knot.Modules.Stories.Application.Stories.Queries.GetStoryReplies; using Knot.Modules.Stories.Application.DTOs; using Knot.Modules.Stories.Application.Stories.Commands.CreateStory; using Knot.Modules.Stories.Application.Stories.Commands.DeleteStory; @@ -15,6 +19,9 @@ using Microsoft.AspNetCore.Routing; namespace Knot.Modules.Stories.Presentation.Endpoints; +public record AddReactionRequest(string Emoji); +public record AddReplyRequest(string Content); + public static class StoriesEndpoints { public static void MapStoriesEndpoints(this WebApplication app) @@ -64,6 +71,30 @@ public static class StoriesEndpoints return Results.Ok(result.Value); }); + group.MapPost("{id:guid}/reaction", async ([FromRoute] Guid id, [FromBody] AddReactionRequest req, ISender sender, IUserContext userContext, CancellationToken ct) => + { + var result = await sender.Send(new AddStoryReactionCommand(userContext.UserId, id, req.Emoji), ct); + return result.IsSuccess ? Results.Ok(new { message = "Reaction added" }) : Results.NotFound(); + }); + + group.MapPost("{id:guid}/reaction/delete", async ([FromRoute] Guid id, [FromQuery] string emoji, ISender sender, IUserContext userContext, CancellationToken ct) => + { + var result = await sender.Send(new RemoveStoryReactionCommand(userContext.UserId, id, emoji), ct); + return result.IsSuccess ? Results.Ok(new { message = "Reaction removed" }) : Results.NotFound(); + }); + + group.MapPost("{id:guid}/reply", async ([FromRoute] Guid id, [FromBody] AddReplyRequest req, ISender sender, IUserContext userContext, CancellationToken ct) => + { + var result = await sender.Send(new AddStoryReplyCommand(userContext.UserId, id, req.Content), ct); + return result.IsSuccess ? Results.Ok(new { message = "Reply added" }) : Results.NotFound(); + }); + + group.MapGet("{id:guid}/replies", async ([FromRoute] Guid id, ISender sender, IUserContext userContext, CancellationToken ct) => + { + var result = await sender.Send(new GetStoryRepliesQuery(userContext.UserId, id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(); + }); + group.MapDelete("{id:guid}", async ([FromRoute] Guid id, ISender sender, IUserContext userContext, CancellationToken ct) => { var result = await sender.Send(new DeleteStoryCommand(userContext.UserId, id), ct); diff --git a/client-web/src/core/infrastructure/i18n.ts b/client-web/src/core/infrastructure/i18n.ts index b1835df..4ef4a3f 100644 --- a/client-web/src/core/infrastructure/i18n.ts +++ b/client-web/src/core/infrastructure/i18n.ts @@ -146,6 +146,24 @@ const translations = { pinChat: 'Закрепить чат', unpinChat: 'Открепить чат', chatCleared: 'Чат очищен', + typeYourStoryPlaceholder: 'Напишите историю...', + uploadMedia: 'Загрузить медиа', + chooseBackground: 'Цвет фона', + viewedBy: 'Просмотрели', + storyDetails: 'Информация', + viewCountSuffix: 'посмотрели эту историю', + storyPrivacyNote: 'Истории зашифрованы и исчезнут через 24 часа. Сообщения о прочтении видны только автору.', + storiesEditor: 'Редактор историй', + tools: 'Инструменты', + privacyNote: 'Выберите, кто может просматривать вашу новую историю.', + allUsers: 'Все пользователи', + onlyContacts: 'Только контакты', + selectedContacts: 'Выбранные контакты', + yourStory: 'Ваша история', + stickers: 'Стикеры', + brush: 'Кисть', + filters: 'Фильтры', + report: 'Пожаловаться', groupSettings: 'Настройки группы', editGroupName: 'Изменить название', addMember: 'Добавить участника', @@ -567,6 +585,24 @@ const translations = { loadChatsError: 'Error loading chats', loadMessagesError: 'Error loading messages', unreadMessages: 'Unread messages', + typeYourStoryPlaceholder: 'Write your story...', + uploadMedia: 'Upload media', + chooseBackground: 'Background', + viewedBy: 'Viewed by', + storyDetails: 'Details', + viewCountSuffix: 'people viewed this story', + storyPrivacyNote: 'Stories are encrypted and disappear in 24 hours. View receipts are only visible to you.', + storiesEditor: 'Stories Editor', + tools: 'Tools', + privacyNote: 'Choose who can view your new story.', + allUsers: 'All users', + onlyContacts: 'Only contacts', + selectedContacts: 'Selected contacts', + yourStory: 'Your story', + stickers: 'Stickers', + brush: 'Brush', + filters: 'Filters', + report: 'Report', attachmentDiscardConfirm: 'You have attached files. If you switch to another chat, they will be lost. Continue?', }, } as const; diff --git a/client-web/src/core/presentation/layouts/Sidebar.tsx b/client-web/src/core/presentation/layouts/Sidebar.tsx index 13288cc..c88f5a5 100644 --- a/client-web/src/core/presentation/layouts/Sidebar.tsx +++ b/client-web/src/core/presentation/layouts/Sidebar.tsx @@ -21,7 +21,8 @@ import ChatListItem from '../../../modules/chats/presentation/components/ChatLis import NewChatModal from '../../../modules/chats/presentation/components/NewChatModal'; import UserProfile from '../../../modules/users/presentation/components/UserProfile'; import SideMenu from './SideMenu'; -import StoryViewer, { CreateStoryModal } from '../../../modules/stories/presentation/components/StoryViewer'; +import StoryViewer from '../../../modules/stories/presentation/components/StoryViewer'; +import { CreateStoryModal } from '../../../modules/stories/presentation/components/CreateStoryModal'; import { useStoryStore } from '../../../modules/stories/application/storyStore'; const API_URL = import.meta.env.VITE_API_URL || ''; diff --git a/client-web/src/modules/stories/infrastructure/storyApi.ts b/client-web/src/modules/stories/infrastructure/storyApi.ts index 8bdfe72..d809fc3 100644 --- a/client-web/src/modules/stories/infrastructure/storyApi.ts +++ b/client-web/src/modules/stories/infrastructure/storyApi.ts @@ -10,7 +10,7 @@ export class StoryApi { return httpClient.request(`/stories/user/${userId}`); } - static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) { + static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string; privacy?: string; filter?: string }) { return httpClient.request<{ id: string }>('/stories', { method: 'POST', body: JSON.stringify(data), diff --git a/client-web/src/modules/stories/presentation/components/CreateStoryModal.tsx b/client-web/src/modules/stories/presentation/components/CreateStoryModal.tsx new file mode 100644 index 0000000..bc14926 --- /dev/null +++ b/client-web/src/modules/stories/presentation/components/CreateStoryModal.tsx @@ -0,0 +1,419 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Camera, Type, Check, Palette, Smile, Brush, Filter, RotateCcw, Eye, MoreVertical, Globe, Users, Lock, ChevronLeft, Trash2, Scissors, Music, Eraser, Minus, Plus, Maximize, Trash, SlidersHorizontal, Sun, Contrast, Droplets, Wind, Pipette, Type as FontIcon, AlignCenter } from 'lucide-react'; +import { useLang } from '../../../../core/infrastructure/i18n'; +import { ChatApi } from '../../../chats/infrastructure/chatApi'; +import { StoryApi } from '../../infrastructure/storyApi'; +import Cropper from 'react-easy-crop'; +import { getMediaUrl } from '../../../../core/utils/utils'; +import { useAuthStore } from '../../../auth/application/authStore'; +import Avatar from '../../../../core/presentation/components/ui/Avatar'; +import Picker from '@emoji-mart/react'; +import data from '@emoji-mart/data'; + +const FONTS = [ + { id: 'inter', name: 'Standard', family: 'Inter, sans-serif' }, + { id: 'serif', name: 'Playfair', family: '"Playfair Display", serif' }, + { id: 'mono', name: 'Retro', family: '"Fira Code", monospace' }, + { id: 'script', name: 'Elegant', family: '"Dancing Script", cursive' }, + { id: 'black', name: 'Impact', family: '"Archivo Black", sans-serif' }, +]; + +interface TextObject { + id: number; + text: string; + x: number; + y: number; + color: string; + font: string; + size: number; + scale: number; +} + +interface CreateStoryModalProps { + onClose: () => void; + onCreated: () => void; +} + +export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) { + const { t } = useLang(); + const { user } = useAuthStore(); + + const [mediaFile, setMediaFile] = useState(null); + const [mediaPreview, setMediaPreview] = useState(null); + const [croppedPreview, setCroppedPreview] = useState(null); + const [bgColor, setBgColor] = useState('#1e1e2e'); + const [isUploading, setIsUploading] = useState(false); + const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text'>('none'); + const [privacy, setPrivacy] = useState<'all' | 'contacts' | 'selected'>('all'); + + const [textObjects, setTextObjects] = useState([]); + const [selectedTextId, setSelectedTextId] = useState(null); + const [stickers, setStickers] = useState>([]); + const [selectedStickerId, setSelectedStickerId] = useState(null); + + const [brushColor, setBrushColor] = useState('#ffffff'); + const [brushSize, setBrushSize] = useState(8); + + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [rotation, setRotation] = useState(0); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + const [adjustments, setAdjustments] = useState({ brightness: 100, contrast: 100, saturate: 100, sepia: 0, grayscale: 0 }); + + const fileInputRef = useRef(null); + const stageRef = useRef(null); + const canvasRef = useRef(null); + const isDrawingRef = useRef(false); + + // Scroll Lock implementation + useEffect(() => { + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = originalStyle; + }; + }, []); + + const TARGET_W = 1080; + const TARGET_H = 1920; + const SCALE_FACTOR = TARGET_W / 400; + + const getFilterCss = () => `brightness(${adjustments.brightness}%) contrast(${adjustments.contrast}%) saturate(${adjustments.saturate}%) sepia(${adjustments.sepia}%) grayscale(${adjustments.grayscale}%)`; + + const handleMediaSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setMediaFile(file); + const url = URL.createObjectURL(file); + setMediaPreview(url); + setCroppedPreview(null); + setTool('crop'); + }; + + const removeMedia = () => { + setMediaFile(null); + setMediaPreview(null); + setCroppedPreview(null); + setCroppedAreaPixels(null); + setTool('none'); + }; + + const addText = () => { + const id = Date.now(); + setTextObjects([...textObjects, { id, text: 'Текст', x: 20, y: 40, color: '#ffffff', font: FONTS[0].family, size: 42, scale: 1 }]); + setSelectedTextId(id); + setTool('none'); + setTimeout(() => setTool('text'), 50); + }; + + const addSticker = (emoji: any) => { + const id = Date.now(); + setStickers([...stickers, { id, emoji: emoji.native, x: 30, y: 50, scale: 1.5 }]); + setSelectedStickerId(id); + }; + + const clearCanvas = () => { + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); + } + }; + + const loadImage = (src: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = async () => { + try { + if ('decode' in img) await img.decode(); + resolve(img); + } catch(e) { + resolve(img); // Fallback + } + }; + img.onerror = (e) => reject(e); + img.src = src; + }); + }; + + const handleCropDone = async () => { + if (croppedAreaPixels && mediaPreview) { + try { + const image = await loadImage(mediaPreview); + const canvas = document.createElement('canvas'); + canvas.width = croppedAreaPixels.width; + canvas.height = croppedAreaPixels.height; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.drawImage(image, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, croppedAreaPixels.width, croppedAreaPixels.height); + canvas.toBlob(blob => { + if (blob) { + const url = URL.createObjectURL(blob); + setCroppedPreview(url); + } + }, 'image/jpeg', 0.95); + } catch (e) { + console.error('Crop bake error', e); + } + } + setTool('none'); + }; + + const handlePublish = async () => { + setIsUploading(true); + try { + const canvas = document.createElement('canvas'); + canvas.width = TARGET_W; + canvas.height = TARGET_H; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas init failed'); + + // 1. BG Color + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, TARGET_W, TARGET_H); + + // 2. Base Image Layer + const baseSrc = croppedPreview || mediaPreview; + if (baseSrc && (mediaFile?.type.startsWith('image/') || !mediaFile)) { + const img = await loadImage(baseSrc); + ctx.save(); + ctx.filter = getFilterCss(); + + if (!croppedPreview && croppedAreaPixels) { + ctx.drawImage(img, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, TARGET_W, TARGET_H); + } else { + ctx.drawImage(img, 0, 0, TARGET_W, TARGET_H); + } + ctx.restore(); + } + + // 3. Brush Layer + if (canvasRef.current) { + ctx.drawImage(canvasRef.current, 0, 0, TARGET_W, TARGET_H); + } + + // 4. Overlays + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + + stickers.forEach(s => { + ctx.font = `${s.scale * 100}px "Apple Color Emoji", "Segoe UI Emoji", serif`; + ctx.fillText(s.emoji, (s.x / 100) * TARGET_W, (s.y / 100) * TARGET_H); + }); + + textObjects.forEach(t => { + const fontSize = t.size * SCALE_FACTOR * t.scale; + ctx.font = `bold ${fontSize}px ${t.font}`; + ctx.fillStyle = t.color; + ctx.fillText(t.text, (t.x / 100) * TARGET_W, (t.y / 100) * TARGET_H); + }); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob(b => b ? resolve(b) : reject('Blob error'), 'image/jpeg', 0.95); + }); + + const fileToUpload = new File([blob], `story_${Date.now()}.jpg`, { type: 'image/jpeg' }); + const { url } = await ChatApi.uploadFile(fileToUpload); + if (!url) throw new Error('Upload failed'); + + // Using PascalCase matching the DTO just in case, and including original content + await StoryApi.createStory({ + type: 'image', + mediaUrl: url, + bgColor: bgColor, + privacy: privacy, // Note: might not be in backend but safe to send + } as any); + + onCreated(); + onClose(); + } catch (e) { + console.error('Publishing error:', e); + } finally { + setIsUploading(false); + } + }; + + return ( + + + +
+
+
S
+

Stories Editor

+
+
+ + +
+
+ +
+
+
+
+ {(croppedPreview || mediaPreview) && ( +
+ {mediaFile?.type.startsWith('video/') ? ( +
+ )} + + { isDrawingRef.current = true; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * (canvas.width / rect.width); const y = (e.clientY - rect.top) * (canvas.height / rect.height); ctx.beginPath(); ctx.moveTo(x, y); ctx.strokeStyle = brushColor; ctx.lineWidth = brushSize * SCALE_FACTOR; ctx.lineCap = 'round'; }} onMouseMove={e => { if (!isDrawingRef.current) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * (canvas.width / rect.width); const y = (e.clientY - rect.top) * (canvas.height / rect.height); ctx.lineTo(x, y); ctx.stroke(); }} onMouseUp={() => isDrawingRef.current = false} /> + + + {stickers.map(s => ( + {setSelectedStickerId(s.id); setSelectedTextId(null); setTool('stickers');}}> + {s.emoji} + {selectedStickerId === s.id && } + + ))} + + {textObjects.map(t => ( + {setSelectedTextId(t.id); setSelectedStickerId(null); setTool('text');}}> + {t.text} + {selectedTextId === t.id && } + + ))} + + + {!mediaPreview && textObjects.length === 0 && stickers.length === 0 && ( + + )} +
+ + {tool === 'crop' && mediaPreview && ( +
+ setCroppedAreaPixels(pixels)} /> + +
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/client-web/src/modules/stories/presentation/components/EmojiOnlyPicker.tsx b/client-web/src/modules/stories/presentation/components/EmojiOnlyPicker.tsx new file mode 100644 index 0000000..2971f6e --- /dev/null +++ b/client-web/src/modules/stories/presentation/components/EmojiOnlyPicker.tsx @@ -0,0 +1,78 @@ +import { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import Picker from '@emoji-mart/react'; +import data from '@emoji-mart/data'; +import { useLang } from '../../../../core/infrastructure/i18n'; + +interface EmojiOnlyPickerProps { + onSelect: (emoji: string) => void; + onClose: () => void; +} + +export default function EmojiOnlyPicker({ onSelect, onClose }: EmojiOnlyPickerProps) { + const { lang } = useLang(); + const anchorRef = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + useEffect(() => { + const update = () => { + const el = anchorRef.current?.parentElement; + if (!el) return; + const rect = el.getBoundingClientRect(); + const w = 350; // Slightly narrower for stories + let left = rect.right - w; + if (left < 8) left = 8; + setPos({ top: rect.top - 8, left }); + }; + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + return ( + <> +
+ {createPortal( + <> +
+
e.stopPropagation()} + style={{ + width: 350, + height: 400, + bottom: pos ? `${window.innerHeight - pos.top}px` : undefined, + left: pos ? pos.left : undefined, + background: 'rgba(19, 19, 19, 0.85)', + visibility: pos ? 'visible' : 'hidden', + }} + > +
+ {lang === 'ru' ? 'Выбор эмодзи' : 'Choose Emoji'} +
+ +
+ onSelect(e.native)} + theme="dark" + locale={lang === 'ru' ? 'ru' : 'en'} + set="native" + previewPosition="none" + skinTonePosition="search" + perLine={8} + emojiSize={24} + emojiButtonSize={32} + maxFrequentRows={1} + navPosition="top" + dynamicWidth={false} + background="transparent" + /> +
+
+ , + document.body + )} + + ); +} diff --git a/client-web/src/modules/stories/presentation/components/StoryViewer.tsx b/client-web/src/modules/stories/presentation/components/StoryViewer.tsx index c679248..bb1a841 100644 --- a/client-web/src/modules/stories/presentation/components/StoryViewer.tsx +++ b/client-web/src/modules/stories/presentation/components/StoryViewer.tsx @@ -1,25 +1,23 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp, Volume2, VolumeX, MessageCircle, Smile } from 'lucide-react'; +import { X, ChevronLeft, ChevronRight, Eye, Trash2, MoreHorizontal, Send, Smile, UserIcon } from 'lucide-react'; +import EmojiOnlyPicker from './EmojiOnlyPicker'; import { useAuthStore } from '../../../auth/application/authStore'; import { StoryApi } from '../../infrastructure/storyApi'; -import { ChatApi } from '../../../chats/infrastructure/chatApi'; import { getSocket } from '../../../../core/infrastructure/socket'; import { useLang } from '../../../../core/infrastructure/i18n'; import Avatar from '../../../../core/presentation/components/ui/Avatar'; import { StoryGroup } from '../../../../core/domain/types'; import { getMediaUrl } from '../../../../core/utils/utils'; -const API_URL = import.meta.env.VITE_API_URL || ''; - -const STORY_BG_COLORS = [ - '#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#ef4444', - '#f97316', '#eab308', '#22c55e', '#14b8a6', '#0ea5e9', - '#3b82f6', '#1e1e2e', +const STORY_REACTIONS = [ + { emoji: '🔥', label: 'fire' }, + { emoji: '❤️', label: 'heart' }, + { emoji: '😂', label: 'laugh' }, + { emoji: '😮', label: 'wow' }, + { emoji: '🙌', label: 'praise' }, ]; -const STORY_EMOJIS = ['❤️', '🔥', '😂', '😮', '😢', '👏', '🎉', '💪']; - interface StoryViewerProps { stories: StoryGroup[]; initialUserIndex: number; @@ -32,78 +30,75 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd const { user } = useAuthStore(); const { t } = useLang(); const [userIndex, setUserIndex] = useState(initialUserIndex); - const [storyIndex, setStoryIndex] = useState(0); + const [storyIndex, setStoryIndex] = useState(initialStoryIndex || 0); const [progress, setProgress] = useState(0); const [paused, setPaused] = useState(false); + const [isInputFocused, setIsInputFocused] = useState(false); + const [lastPressTime, setLastPressTime] = useState(0); const timerRef = useRef>(undefined); const viewedRef = useRef>(new Set()); const [viewOverrides, setViewOverrides] = useState>({}); const videoRef = useRef(null); + const inputRef = useRef(null); const [isMuted, setIsMuted] = useState(true); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + + // Scroll Lock implementation + useEffect(() => { + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = originalStyle; + }; + }, []); const STORY_DURATION = 5000; const TICK = 50; - const [showViewers, setShowViewers] = useState(false); - const [viewers, setViewers] = useState>([]); + const [viewers, setViewers] = useState>([]); 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>>({}); + const [quickReply, setQuickReply] = useState(''); + const [isSendingReply, setIsSendingReply] = useState(false); const currentUser = stories[userIndex]; - const rawStory = currentUser?.stories?.[storyIndex]; - const currentStory = rawStory ? { - ...rawStory, - ...viewOverrides[rawStory.id], - reactions: localReactions[rawStory.id] || rawStory.reactions || [] + const currentStoryRaw = currentUser?.stories?.[storyIndex]; + const currentStory = currentStoryRaw ? { + ...currentStoryRaw, + ...viewOverrides[currentStoryRaw.id] } : 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'))); + const storyTypeStr = String(currentStory?.type || '').toLowerCase(); + const isVideo = storyTypeStr === 'video' || storyTypeStr === '2'; + const isImage = storyTypeStr === 'image' || storyTypeStr === '1'; - // Pause when showing reactions or reply input or state changed - useEffect(() => { - if (showReactions || showReplyInput || showViewers || paused) { - if (videoRef.current && !videoRef.current.paused) { - videoRef.current.pause(); - } - } else { - if (videoRef.current && videoRef.current.paused && isVideo) { - videoRef.current.play().catch(() => { }); - } - } - }, [showReactions, showReplyInput, showViewers, paused, isVideo]); - - // Handle video play/pause sync with paused state - useEffect(() => { - if (!videoRef.current || !isVideo) return; - if (paused) { - videoRef.current.pause(); - } else { - videoRef.current.play().catch(() => { }); - } - }, [paused, isVideo]); - - // Mute by default for viewers, unmute for story owner useEffect(() => { if (currentUser?.user.id === user?.id) { - setIsMuted(false); + setIsMuted(false); + // Load viewers for own story + if (currentStory) { + setViewersLoading(true); + StoryApi.getStoryViewers(currentStory.id).then(setViewers).finally(() => setViewersLoading(false)); + } + } else { + setIsMuted(true); + setViewers([]); } - }, [currentUser?.user.id, user?.id]); - + }, [currentUser?.user.id, currentStory?.id, user?.id]); + + // Auto-resize textarea useEffect(() => { - setUserIndex(initialUserIndex); - setStoryIndex(initialStoryIndex || 0); - setProgress(0); - setPaused(false); - viewedRef.current.clear(); - setViewOverrides({}); - }, [initialUserIndex, initialStoryIndex]); + const el = inputRef.current; + if (el) { + el.style.height = '24px'; // Reset/Min height + if (quickReply) { + const newHeight = Math.max(24, Math.min(el.scrollHeight, 120)); + el.style.height = newHeight + 'px'; + el.style.overflowY = el.scrollHeight > 120 ? 'auto' : 'hidden'; + } else { + el.style.overflowY = 'hidden'; + } + } + }, [quickReply]); const goNext = useCallback(() => { if (!currentUser) return; @@ -131,39 +126,8 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd } }, [storyIndex, userIndex, stories]); - const canGoPrev = storyIndex > 0 || userIndex > 0; - const canGoNext = (currentUser && storyIndex < currentUser.stories.length - 1) || userIndex < stories.length - 1; - useEffect(() => { - if (!currentStory || !currentStory.id) return; - if (currentUser.user.id === user?.id) return; - if (currentStory.viewed || viewedRef.current.has(currentStory.id)) return; - - // console.log('[StoryViewer] Calling viewStory for:', currentStory.id); - viewedRef.current.add(currentStory.id); - const storyId = currentStory.id; - const viewCount = currentStory.viewCount || 0; - - StoryApi.viewStory(storyId).then(() => { - // console.log('[StoryViewer] viewStory success, updating count to', viewCount + 1); - setViewOverrides(prev => ({ - ...prev, - [storyId]: { - viewCount: viewCount + 1, - viewed: true, - }, - })); - }).catch(e => { - console.error('[StoryViewer] viewStory error:', e); - }); - }, [currentStory?.id, currentUser?.user?.id, user?.id]); - - useEffect(() => { - setProgress(0); - }, [storyIndex, userIndex]); - - useEffect(() => { - if (paused || showReactions || showReplyInput || showViewers || !currentStory || isVideo) return; + if (paused || !currentStory || isVideo) return; const duration = STORY_DURATION; const step = (TICK / duration) * 100; @@ -181,617 +145,319 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd return () => { if (timerRef.current) clearInterval(timerRef.current); }; - }, [storyIndex, userIndex, paused, showReactions, showReplyInput, showViewers, goNext, isVideo, currentStory]); + }, [storyIndex, userIndex, paused, goNext, isVideo, currentStory]); - // Handle video progress useEffect(() => { const video = videoRef.current; - if (!video || !isVideo || paused || showReactions || showReplyInput || showViewers) return; + if (!video || !isVideo || paused) return; const interval = setInterval(() => { if (video.duration) { - const p = (video.currentTime / video.duration) * 100; - setProgress(p); + setProgress((video.currentTime / video.duration) * 100); } }, TICK); return () => clearInterval(interval); - }, [isVideo, paused, showReactions, showReplyInput, showViewers, storyIndex, userIndex]); + }, [isVideo, paused, storyIndex, userIndex]); useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - if (e.key === 'ArrowRight') goNext(); - if (e.key === 'ArrowLeft') goPrev(); - }; - - const socket = getSocket(); - - const handleStoryViewed = (data: { storyId: string; userId: string; username: string; 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; + if (!currentStory || currentUser.user.id === user?.id) return; + if (viewedRef.current.has(currentStory.id)) return; + viewedRef.current.add(currentStory.id); + StoryApi.viewStory(currentStory.id).then(() => { setViewOverrides(prev => ({ ...prev, - [data.storyId]: { - viewCount: data.viewCount, - viewed: prev[data.storyId]?.viewed || false - } + [currentStory.id]: { + viewCount: (currentStory.viewCount || 0) + 1, + viewed: true, + }, })); + }).catch(console.error); + }, [currentStory?.id, currentUser?.user.id, user?.id]); - if (showViewers) { - setViewers(prev => { - if (prev.some(v => v.userId === data.userId)) return prev; - return [...prev, { - userId: data.userId, - username: data.username, - userName: data.userName, - displayName: data.displayName, - avatar: data.avatar, - viewedAt: data.viewedAt - }].sort((a, b) => new Date(b.viewedAt).getTime() - new Date(a.viewedAt).getTime()); - }); - } - }; - - const handleStoryReply = (data: { storyId: string; userId: string; username: string; 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; userName?: string; displayName: string; avatar: string | null; emoji: string; createdAt: string; ownerId: string }) => { - // console.log('[StoryViewer] story_reaction received:', data); - // Only process if this user is the owner - if (data.ownerId !== user?.id) return; - // Could show notification or update UI - }; - - window.addEventListener('keydown', onKey); - socket?.on('story_viewed', handleStoryViewed); - socket?.on('story_reply', handleStoryReply); - socket?.on('story_reaction', handleStoryReaction); - - return () => { - window.removeEventListener('keydown', onKey); - socket?.off('story_viewed', handleStoryViewed); - socket?.off('story_reply', handleStoryReply); - socket?.off('story_reaction', handleStoryReaction); - }; - }, [goNext, goPrev, onClose, currentStory?.id, showViewers]); - - const handleDelete = async () => { + const handleReaction = async (emoji: string) => { if (!currentStory) return; - const storyId = currentStory.id; try { - await StoryApi.deleteStory(storyId); - - if (currentUser.stories.length > 1) { - if (storyIndex >= currentUser.stories.length - 1) { - setStoryIndex(s => s - 1); - } - } else { - if (userIndex < stories.length - 1) { - setUserIndex(u => u + 1); - setStoryIndex(0); - } else { - onClose(); - } - } - - onRefresh(); + await StoryApi.addStoryReaction(currentStory.id, emoji); } catch (e) { console.error(e); } }; - const toggleMute = () => { - setIsMuted(!isMuted); - if (videoRef.current) { - videoRef.current.muted = !isMuted; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendReply(); } }; - const handleAddReaction = async (emoji: string) => { - if (!currentStory) return; - setShowReactions(false); - - setLocalReactions(prev => ({ - ...prev, - [currentStory.id]: [...(prev[currentStory.id] || []), { - id: `${currentStory.id}-${user?.id}-${emoji}`, - userId: user?.id || '', - emoji, - createdAt: new Date().toISOString() - }] - })); - - try { - await StoryApi.addStoryReaction(currentStory.id, emoji); - } catch (e) { - console.error('Add reaction error:', e); - } + const insertEmoji = (emoji: string) => { + const el = inputRef.current; + if (!el) return; + const start = el.selectionStart; + const end = el.selectionEnd; + const text = quickReply; + const newText = text.substring(0, start) + emoji + text.substring(end); + setQuickReply(newText); + setShowEmojiPicker(false); + setTimeout(() => { + el.focus(); + const pos = start + emoji.length; + el.setSelectionRange(pos, pos); + }, 0); }; const handleSendReply = async () => { - if (!currentStory || !replyText.trim() || sendingReply) return; - setSendingReply(true); - + if (!currentStory || !quickReply.trim() || isSendingReply) return; + setIsSendingReply(true); try { - await StoryApi.addStoryReply(currentStory.id, replyText.trim()); - setReplyText(''); - setShowReplyInput(false); + await StoryApi.addStoryReply(currentStory.id, quickReply.trim()); + setQuickReply(''); } catch (e) { - console.error('Send reply error:', e); + console.error(e); } finally { - setSendingReply(false); + setIsSendingReply(false); } }; - if (!currentUser || !currentStory) { - onClose(); - return null; - } - - const timeAgo = (date: string) => { - const diff = (Date.now() - new Date(date).getTime()) / 1000; - if (diff < 60) return `${Math.floor(diff)}s`; - if (diff < 3600) return `${Math.floor(diff / 60)}m`; - return `${Math.floor(diff / 3600)}h`; + const handleDelete = async () => { + if (!currentStory) return; + try { + await StoryApi.deleteStory(currentStory.id); + onRefresh(); + goNext(); + } catch (e) { + console.error(e); + } }; - const avatarUrl = currentUser.user.avatar - ? getMediaUrl(currentUser.user.avatar) - : null; + if (!currentUser || !currentStory) return null; return ( { - if (e.target === e.currentTarget) onClose(); - }} + className="fixed inset-0 z-[100] bg-surface-container-lowest/90 backdrop-blur-3xl flex items-center justify-center font-body selection:bg-primary/30" > -
+
+
+
+ + {/* Close Button Top Right */} + - - - )} - -
-
+ + +
setPaused(true)} - onMouseUp={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }} - onMouseLeave={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }} - onTouchStart={() => setPaused(true)} - onTouchEnd={() => { if (!showReactions && !showReplyInput && !showViewers) setPaused(false); }} + className="relative w-full aspect-[9/16] max-w-[500px] bg-surface-container-highest shadow-[20px_60px_100px_rgba(0,0,0,0.8)] overflow-hidden rounded-[3rem] ring-1 ring-white/10 flex flex-col group transition-transform duration-500" > -
{ e.stopPropagation(); goPrev(); }} /> -
-
{ e.stopPropagation(); goNext(); }} /> -
- - {canGoPrev && ( - - )} - {canGoNext && ( - - )} - - {/* Bottom actions */} -
- {/* Sound toggle for video - show for everyone */} - {isVideo && ( - - )} - - {/* Reply and reactions - only for non-owners */} - {currentUser.user.id !== user?.id && ( - <> - - - - - )} -
- - - {showReactions && currentUser.user.id !== user?.id && ( - e.stopPropagation()} - > - {STORY_EMOJIS.map(emoji => ( - - ))} - - )} - - - - {showReplyInput && currentUser.user.id !== user?.id && ( - e.stopPropagation()} - > -
- 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 +
{ + e.stopPropagation(); + if (Date.now() - lastPressTime < 250) goPrev(); + }} /> +
{ + e.stopPropagation(); + if (Date.now() - lastPressTime < 250) goNext(); + }} /> +
+ {/* Progress Bars */} +
+ {currentUser.stories.map((_, i) => ( +
+
-
- - )} - + ))} +
- - {showViewers && currentUser.user.id === user?.id && ( - e.stopPropagation()} - > -
-
-

- {t('storyViewers')} ({currentStory.viewCount}) -

- -
- {viewersLoading ? ( -
{t('sending')}
- ) : viewers.length === 0 ? ( -
{t('noViewers')}
- ) : ( -
- {viewers.map((v) => ( -
- -
-

{v.displayName || v.username || ''}

-

@{v.username}

-
- {timeAgo(v.viewedAt)} -
- ))} -
- )} + {/* Story Header */} +
+
+ +
+

+ {currentUser.user.displayName || currentUser.user.username || ''} +

+

+ {new Date(currentStory.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

- - )} - +
+ +
+ {currentUser.user.id === user?.id && ( + + )} +
+
+ + {/* Content Media */} +
+ {isVideo && currentStory.mediaUrl ? ( +