Фиксы по историям

This commit is contained in:
Халимов Рустам
2026-04-03 21:13:28 +03:00
parent 2ab5b295e8
commit a04f04a448
13 changed files with 233 additions and 171 deletions
@@ -10,7 +10,7 @@ using Knot.Modules.Stories.Domain;
namespace Knot.Modules.Stories.Application.Stories.Commands.CreateStory; namespace Knot.Modules.Stories.Application.Stories.Commands.CreateStory;
public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, string? Content, string? BgColor) : ICommand<Guid>; public record CreateStoryCommand(Guid UserId, string Type, string? MediaUrl, string? Content, string? BgColor, bool IsMuted) : ICommand<Guid>;
internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid> internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCommand, Guid>
{ {
@@ -27,7 +27,7 @@ internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCom
{ {
if (!Enum.TryParse<StoryType>(request.Type, true, out var parsedType)) if (!Enum.TryParse<StoryType>(request.Type, true, out var parsedType))
{ {
parsedType = StoryType.Text; parsedType = StoryType.Image;
} }
// Проверка Klipy // Проверка Klipy
@@ -45,7 +45,8 @@ internal sealed class CreateStoryCommandHandler : ICommandHandler<CreateStoryCom
parsedType, parsedType,
request.MediaUrl, request.MediaUrl,
request.Content, request.Content,
request.BgColor); request.BgColor,
request.IsMuted);
await _storyRepository.AddAsync(story, cancellationToken); await _storyRepository.AddAsync(story, cancellationToken);
@@ -3,4 +3,4 @@ using System;
using Knot.Modules.Stories.Application.Abstractions; using Knot.Modules.Stories.Application.Abstractions;
namespace Knot.Modules.Stories.Application.DTOs; namespace Knot.Modules.Stories.Application.DTOs;
public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor); public record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor, bool IsMuted);
@@ -10,6 +10,7 @@ public record StoryDto(
string? MediaUrl, string? MediaUrl,
string? Content, string? Content,
string? BgColor, string? BgColor,
bool IsMuted,
DateTime CreatedAt, DateTime CreatedAt,
int ViewCount, int ViewCount,
bool Viewed bool Viewed
@@ -81,6 +81,7 @@ internal sealed class GetStoriesQueryHandler : IQueryHandler<GetStoriesQuery, Li
s.MediaUrl, s.MediaUrl,
s.Content, s.Content,
s.BgColor, s.BgColor,
s.IsMuted,
s.CreatedAt, s.CreatedAt,
s.ViewsCount, s.ViewsCount,
viewedStoriesIds.Contains(s.Id) viewedStoriesIds.Contains(s.Id)
@@ -56,6 +56,7 @@ internal sealed class GetUserStoriesQueryHandler : IQueryHandler<GetUserStoriesQ
s.MediaUrl, s.MediaUrl,
s.Content, s.Content,
s.BgColor, s.BgColor,
s.IsMuted,
s.CreatedAt, s.CreatedAt,
s.ViewsCount, s.ViewsCount,
viewedStoriesIds.Contains(s.Id) viewedStoriesIds.Contains(s.Id)
+5 -3
View File
@@ -27,6 +27,7 @@ public class Story : Entity<Guid>
public string? MediaUrl { get; private set; } public string? MediaUrl { get; private set; }
public string? Content { get; private set; } public string? Content { get; private set; }
public string? BgColor { get; private set; } public string? BgColor { get; private set; }
public bool IsMuted { get; private set; }
public DateTime CreatedAt { get; private set; } public DateTime CreatedAt { get; private set; }
public int ViewsCount { get; private set; } public int ViewsCount { get; private set; }
public List<StoryReaction> Reactions { get; private set; } = new(); public List<StoryReaction> Reactions { get; private set; } = new();
@@ -34,19 +35,20 @@ public class Story : Entity<Guid>
protected Story() : base(Guid.NewGuid()) { } protected Story() : base(Guid.NewGuid()) { }
internal Story(Guid id, Guid userId, StoryType type, string? mediaUrl, string? content, string? bgColor) : base(id) internal Story(Guid id, Guid userId, StoryType type, string? mediaUrl, string? content, string? bgColor, bool isMuted) : base(id)
{ {
UserId = userId; UserId = userId;
Type = type; Type = type;
MediaUrl = mediaUrl; MediaUrl = mediaUrl;
Content = content; Content = content;
BgColor = bgColor; BgColor = bgColor;
IsMuted = isMuted;
CreatedAt = DateTime.UtcNow; CreatedAt = DateTime.UtcNow;
} }
public static Story Create(Guid userId, StoryType type, string? mediaUrl, string? content, string? bgColor) public static Story Create(Guid userId, StoryType type, string? mediaUrl, string? content, string? bgColor, bool isMuted = false)
{ {
return new Story(Guid.NewGuid(), userId, type, mediaUrl, content, bgColor); return new Story(Guid.NewGuid(), userId, type, mediaUrl, content, bgColor, isMuted);
} }
public void IncrementViewsCount() public void IncrementViewsCount()
@@ -36,7 +36,7 @@ public static class StoriesEndpoints
group.MapPost("", async ([FromBody] CreateStoryRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => group.MapPost("", async ([FromBody] CreateStoryRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{ {
var result = await sender.Send(new CreateStoryCommand(userContext.UserId, request.Type, request.MediaUrl, request.Content, request.BgColor), ct); var result = await sender.Send(new CreateStoryCommand(userContext.UserId, request.Type, request.MediaUrl, request.Content, request.BgColor, request.IsMuted), ct);
return Results.Ok(new { id = result.Value }); return Results.Ok(new { id = result.Value });
}); });
+1
View File
@@ -150,6 +150,7 @@ export interface Story {
mediaUrl: string | null; mediaUrl: string | null;
content: string | null; content: string | null;
bgColor: string | null; bgColor: string | null;
isMuted?: boolean;
createdAt: string; createdAt: string;
expiresAt: string; expiresAt: string;
viewCount: number; viewCount: number;
@@ -13,7 +13,6 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
const menuItems = [ const menuItems = [
{ id: 'chats', icon: 'chat', label: t('chats') }, { id: 'chats', icon: 'chat', label: t('chats') },
{ id: 'contacts', icon: 'contacts', label: t('contacts') }, { id: 'contacts', icon: 'contacts', label: t('contacts') },
{ id: 'settings', icon: 'settings', label: t('settings') },
]; ];
return ( return (
@@ -191,25 +191,22 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const scrollTimeoutRef = useRef<any>(null); const scrollTimeoutRef = useRef<any>(null);
const prevChatIdRef = useRef<string | null>(activeChat); const prevChatIdRef = useRef<string | null>(activeChat);
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (СТРОГО ПО ID) // 1. СОХРАНЕНИЕ ПОЗИЦИИ (ЯКОРНОЕ ПО MESSAGE ID)
const saveScrollPosition = useCallback((targetChatId?: string) => { const saveScrollPosition = useCallback((targetChatId?: string) => {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
const chatId = targetChatId || activeChat; const chatId = targetChatId || activeChat;
// НЕ сохраняем, если чат ещё не восстановил свою позицию или в процессе загрузки
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return; if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
// ГАРАНТИЯ: Если сообщения в стейте НЕ от этого чата - не пишем в память мусор // Проверка: сообщения в стейте должны быть от целевого чата
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return; if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
// КРИТИЧНО: Если этот чат уже помечен как находящийся внизу, const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
// не позволяем автоматике перезаписать это якорем (защита "второго клика")
if (localStorage.getItem(`chat_at_bottom_${chatId}`) === 'true') { if (isAtBottomNow) {
const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 150; localStorage.setItem(`chat_at_bottom_${chatId}`, 'true');
if (isNearBottomNow) { localStorage.removeItem(`chat_anchor_${chatId}`);
localStorage.removeItem(`chat_anchor_${chatId}`); return;
return;
}
} }
const messageElements = container.querySelectorAll('[data-message-id]'); const messageElements = container.querySelectorAll('[data-message-id]');
@@ -218,9 +215,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
let anchor = null; let anchor = null;
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
// Находим первое сообщение, которое пересекает верхнюю границу видимости
for (const el of messageElements) { for (const el of messageElements) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
if (rect.top >= containerRect.top) { if (rect.bottom > containerRect.top) {
anchor = { anchor = {
id: el.getAttribute('data-message-id'), id: el.getAttribute('data-message-id'),
offset: rect.top - containerRect.top offset: rect.top - containerRect.top
@@ -231,13 +229,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (anchor && anchor.id) { if (anchor && anchor.id) {
localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor)); localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor));
}
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
if (isAtBottomNow) {
localStorage.setItem(`chat_at_bottom_${chatId}`, 'true');
localStorage.removeItem(`chat_anchor_${chatId}`);
} else {
localStorage.removeItem(`chat_at_bottom_${chatId}`); localStorage.removeItem(`chat_at_bottom_${chatId}`);
} }
}, [activeChat, scrollReady, chatMessages]); }, [activeChat, scrollReady, chatMessages]);
@@ -247,32 +238,23 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
if (!container || !activeChat) return false; if (!container || !activeChat) return false;
// КРИТИЧНО: Ждем, пока в хранилище сообщений появятся данные именно от активного чата if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false;
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) {
return false;
}
if (chatMessages.length === 0) return true; if (chatMessages.length === 0) return true;
// Сначала проверяем, был ли пользователь внизу (защита "второго клика") // ПРИОРИТЕТ 1: Если чат внизу (после второго клика или скролла)
if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') { if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') {
scrollToBottom(false); container.scrollTop = container.scrollHeight;
// Агрессивные повторы, если контент еще догружается (картинки и т.д.)
for (const delay of [100, 300, 600, 1000]) {
setTimeout(() => {
if (activeChat === prevChatIdRef.current) scrollToBottom(false);
}, delay);
}
return true; return true;
} }
// Восстановление по якорю сообщения // ПРИОРИТЕТ 2: Восстановление по якорю сообщения
const saved = localStorage.getItem(`chat_anchor_${activeChat}`); const saved = localStorage.getItem(`chat_anchor_${activeChat}`);
if (saved) { if (saved) {
try { try {
const { id, offset } = JSON.parse(saved); const { id, offset } = JSON.parse(saved);
let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement; let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement;
// Поиск ближайшего, если точное сообщение еще не загружено
if (!el) { if (!el) {
const msgIndex = chatMessages.findIndex(m => m.id === id); const msgIndex = chatMessages.findIndex(m => m.id === id);
if (msgIndex !== -1) { if (msgIndex !== -1) {
@@ -282,15 +264,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
} }
} }
} }
if (el) { if (el) {
container.scrollTop = el.offsetTop - offset; container.scrollTop = el.offsetTop - offset;
return true; return true;
} }
if (chatMessages.some(m => m.id === id)) return false; } catch (e) { console.error('Anchor restoration failed', e); }
} catch (e) { console.error(e); }
} }
// Если ничего нет - к непрочитанным или вниз // ПРИОРИТЕТ 3: Непрочитанные или низ
const firstUnread = chatMessages.find(m => m.senderId !== user?.id && !m.readBy?.some(r => r.userId === user?.id)); const firstUnread = chatMessages.find(m => m.senderId !== user?.id && !m.readBy?.some(r => r.userId === user?.id));
if (firstUnread) { if (firstUnread) {
const el = document.getElementById(`msg-${firstUnread.id}`) || document.getElementById('unread-divider'); const el = document.getElementById(`msg-${firstUnread.id}`) || document.getElementById('unread-divider');
@@ -299,17 +281,17 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
return true; return true;
} }
} }
scrollToBottom(false);
return true;
}, [activeChat, chatMessages, scrollToBottom, user?.id]);
// 3. ОБЗЕРВЕР И ИНИЦИАЛИЗАЦИЯ container.scrollTop = container.scrollHeight;
return true;
}, [activeChat, chatMessages, user?.id]);
// 3. ОБЗЕРВЕР И УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ
useEffect(() => { useEffect(() => {
if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return; if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return;
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
// Игнорируем замеры, пока мы не отпозиционировали чат изначально
if (isInitializingRef.current) return; if (isInitializingRef.current) return;
if (!scrollReady) { if (!scrollReady) {
@@ -318,17 +300,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
isInitializingRef.current = false; isInitializingRef.current = false;
} }
} else if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') { } else if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') {
const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 300; // Удержание внизу при росте контента
if (isNearBottomNow) { container.scrollTop = container.scrollHeight;
scrollToBottom(true);
}
} }
}); });
const messagesDiv = container.querySelector('.space-y-1'); const messagesDiv = container.querySelector('.space-y-1');
if (messagesDiv) observer.observe(messagesDiv); if (messagesDiv) observer.observe(messagesDiv);
// Принудительно пробуем восстановить, если сообщения ПРАВИЛЬНЫЕ // Попытка восстановления при появлении правильных сообщений
if (chatMessages.length > 0 && chatMessages[0].chatId === activeChat && !scrollReady) { if (chatMessages.length > 0 && chatMessages[0].chatId === activeChat && !scrollReady) {
if (restoreScrollPosition()) { if (restoreScrollPosition()) {
setScrollReady(true); setScrollReady(true);
@@ -337,30 +317,29 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [activeChat, isLoadingMessages, chatMessages, restoreScrollPosition, scrollReady, scrollToBottom]); }, [activeChat, isLoadingMessages, chatMessages, restoreScrollPosition, scrollReady]);
// 4. ПЕРЕКЛЮЧЕНИЕ ЧАТОВ (ФИКС RACE CONDITION) // 4. ПЕРЕКЛЮЧЕНИЕ ЧАТОВ (СИНХРОННОЕ)
useLayoutEffect(() => { useLayoutEffect(() => {
if (activeChat !== prevChatIdRef.current) { if (activeChat !== prevChatIdRef.current) {
// СРАЗУ блокируем сохранение, чтобы handleScroll ничего не записал при изменении высоты
isInitializingRef.current = true; isInitializingRef.current = true;
isScrollingToBottomRef.current = false;
// Сохраняем позицию ПРЕДЫДУЩЕГО чата ПЕРЕД установкой новых данных // Сохраняем позицию старого чата
if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) { if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) {
saveScrollPosition(prevChatIdRef.current); saveScrollPosition(prevChatIdRef.current);
} }
setScrollReady(false); setScrollReady(false);
prevChatIdRef.current = activeChat; prevChatIdRef.current = activeChat;
if (messagesContainerRef.current) { if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = 0; messagesContainerRef.current.scrollTop = 0;
} }
// Таймер защиты на случай медленного рендера
const timer = setTimeout(() => { const timer = setTimeout(() => {
isInitializingRef.current = false; isInitializingRef.current = false;
}, 1000); }, 600);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
@@ -374,17 +353,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}, []); }, []);
const handleScroll = () => { const handleScroll = () => {
// В период инициализации (1.2с) или принудительного скролла вниз - игнорируем любые события.
if (isInitializingRef.current || isScrollingToBottomRef.current) return; if (isInitializingRef.current || isScrollingToBottomRef.current) return;
checkScrollPosition(); checkScrollPosition();
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
if (container && activeChat) { if (container && activeChat) {
const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 120;
if (!isNearBottomNow) localStorage.removeItem(`chat_at_bottom_${activeChat}`);
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 200); scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150);
if (container.scrollTop < 100 && hasMoreMessages[activeChat] && !isLoadingMessages) { if (container.scrollTop < 100 && hasMoreMessages[activeChat] && !isLoadingMessages) {
useChatStore.getState().loadMessages(activeChat, false, true); useChatStore.getState().loadMessages(activeChat, false, true);
@@ -395,18 +370,26 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
useEffect(() => { useEffect(() => {
const handleScrollEvent = (e: any) => { const handleScrollEvent = (e: any) => {
if (e.detail?.chatId === activeChat) { if (e.detail?.chatId === activeChat) {
isScrollingToBottomRef.current = true; // Принудительный сброс режима (Второй Клик)
localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true'); localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true');
localStorage.removeItem(`chat_anchor_${activeChat}`); localStorage.removeItem(`chat_anchor_${activeChat}`);
scrollToBottom(true);
isScrollingToBottomRef.current = true;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}
setTimeout(() => { setTimeout(() => {
isScrollingToBottomRef.current = false; isScrollingToBottomRef.current = false;
}, 500); }, 1000);
} }
}; };
window.addEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent); window.addEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent);
return () => window.removeEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent); return () => window.removeEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent);
}, [activeChat, scrollToBottom]); }, [activeChat]);
// Read receipts using IntersectionObserver // Read receipts using IntersectionObserver
const sentReadIdsRef = useRef<Set<string>>(new Set()); const sentReadIdsRef = useRef<Set<string>>(new Set());
@@ -1203,8 +1186,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Actually target is almost always older if not found. // Actually target is almost always older if not found.
// If we don't have targetCreatedAt, we guess (up to 100 attempts) // If we don't have targetCreatedAt, we guess (up to 100 attempts)
if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) { if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) {
// Should have been found by tryScroll, but lets try one last time // Should have been found by tryScroll, but lets try one last time
if (tryScroll()) { found = true; break; } if (tryScroll()) { found = true; break; }
} }
if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break; if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break;
@@ -1220,8 +1203,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Stop if we have gone way past the target date // Stop if we have gone way past the target date
if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) { if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
// We are 1 hour before the message and still haven't found it? might be deleted // We are 1 hour before the message and still haven't found it? might be deleted
if (i > 10) break; if (i > 10) break;
} }
} }
@@ -10,7 +10,7 @@ export class StoryApi {
return httpClient.request<StoryGroup>(`/stories/user/${userId}`); return httpClient.request<StoryGroup>(`/stories/user/${userId}`);
} }
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string; privacy?: string; filter?: string }) { static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string; privacy?: string; filter?: string; isMuted?: boolean }) {
return httpClient.request<{ id: string }>('/stories', { return httpClient.request<{ id: string }>('/stories', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -44,6 +44,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
const [croppedPreview, setCroppedPreview] = useState<string | null>(null); const [croppedPreview, setCroppedPreview] = useState<string | null>(null);
const [bgColor, setBgColor] = useState('#1e1e2e'); const [bgColor, setBgColor] = useState('#1e1e2e');
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text'>('none'); const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text'>('none');
const [privacy, setPrivacy] = useState<'all' | 'contacts' | 'selected'>('all'); const [privacy, setPrivacy] = useState<'all' | 'contacts' | 'selected'>('all');
@@ -88,7 +89,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
setMediaPreview(url); setMediaPreview(url);
setCroppedPreview(null); setCroppedPreview(null);
setTool('crop'); setTool(file.type.startsWith('video/') ? 'none' : 'crop');
}; };
const removeMedia = () => { const removeMedia = () => {
@@ -164,66 +165,86 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
const handlePublish = async () => { const handlePublish = async () => {
setIsUploading(true); setIsUploading(true);
try { try {
const canvas = document.createElement('canvas'); const isVideo = mediaFile?.type.startsWith('video/');
canvas.width = TARGET_W; let url = '';
canvas.height = TARGET_H; let type = isVideo ? 'video' : 'image';
const ctx = canvas.getContext('2d'); let content = '';
if (!ctx) throw new Error('Canvas init failed');
// 1. BG Color if (isVideo && mediaFile) {
ctx.fillStyle = bgColor; // Handle Video: upload original file
ctx.fillRect(0, 0, TARGET_W, TARGET_H); const res = await StoryApi.uploadVideoToStory(mediaFile);
url = res.url;
// Serialize overlays as metadata
content = JSON.stringify({
stickers: stickers.map(s => ({ ...s, emoji: s.emoji })), // Ensure emoji is string
textObjects: textObjects.map(t => ({ ...t }))
});
} else {
// Handle Image/Text: bake into canvas
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');
// 2. Base Image Layer // 1. BG Color
const baseSrc = croppedPreview || mediaPreview; ctx.fillStyle = bgColor;
if (baseSrc && (mediaFile?.type.startsWith('image/') || !mediaFile)) { ctx.fillRect(0, 0, TARGET_W, TARGET_H);
const img = await loadImage(baseSrc);
ctx.save();
ctx.filter = getFilterCss();
if (!croppedPreview && croppedAreaPixels) { // 2. Base Image Layer
ctx.drawImage(img, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, TARGET_W, TARGET_H); const baseSrc = croppedPreview || mediaPreview;
} else { if (baseSrc && (mediaFile?.type.startsWith('image/') || !mediaFile)) {
ctx.drawImage(img, 0, 0, TARGET_W, TARGET_H); 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();
} }
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<Blob>((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 uploadRes = await ChatApi.uploadFile(fileToUpload);
url = uploadRes.url;
} }
// 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<Blob>((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'); if (!url) throw new Error('Upload failed');
// Using PascalCase matching the DTO just in case, and including original content
await StoryApi.createStory({ await StoryApi.createStory({
type: 'image', type,
mediaUrl: url, mediaUrl: url,
bgColor: bgColor, bgColor: bgColor,
privacy: privacy, // Note: might not be in backend but safe to send content: content,
privacy: privacy,
isMuted: isVideo ? isMuted : false,
} as any); } as any);
onCreated(); onCreated();
@@ -242,12 +263,20 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
`}</style> `}</style>
<header className="h-20 px-8 flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] z-50 shadow-2xl"> <header className="h-20 px-8 flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] z-50 shadow-2xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-6">
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center text-primary border border-primary/20 italic font-black shadow-[0_0_20px_rgba(var(--primary-rgb),0.3)]">S</div> <button onClick={onClose} className="w-10 h-10 rounded-full hover:bg-white/5 flex items-center justify-center text-zinc-400 hover:text-white transition-all">
<h1 className="text-sm font-black uppercase tracking-widest italic text-zinc-400">Stories Editor</h1> <X size={24} />
</button>
<h1 className="text-sm font-black uppercase tracking-[0.2em] italic text-zinc-400">{t('storiesEditor')}</h1>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); }} className="px-6 py-3 text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white border border-white/5 rounded-xl hover:bg-white/5 transition-all">Сброс</button> {mediaFile?.type.startsWith('video/') && (
<button onClick={() => setIsMuted(!isMuted)} className={`p-3 rounded-xl border transition-all ${isMuted ? 'bg-red-500/10 border-red-500/40 text-red-500' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={isMuted ? "Включить звук" : "Отключить звук"}>
{isMuted ? <Wind size={18} /> : <Droplets size={18} />}
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{isMuted ? 'Без звука' : 'Со звуком'}</span>
</button>
)}
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); setIsMuted(false); }} className="px-6 py-3 text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white border border-white/5 rounded-xl hover:bg-white/5 transition-all">Сброс</button>
<button onClick={handlePublish} disabled={isUploading} className="px-8 py-3 bg-primary text-on-primary rounded-xl font-black text-xs uppercase tracking-widest hover:scale-105 active:scale-95 disabled:opacity-50 transition-all shadow-xl shadow-primary/20"> <button onClick={handlePublish} disabled={isUploading} className="px-8 py-3 bg-primary text-on-primary rounded-xl font-black text-xs uppercase tracking-widest hover:scale-105 active:scale-95 disabled:opacity-50 transition-all shadow-xl shadow-primary/20">
{isUploading ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : 'Опубликовать'} {isUploading ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : 'Опубликовать'}
</button> </button>
@@ -261,7 +290,9 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
{(croppedPreview || mediaPreview) && ( {(croppedPreview || mediaPreview) && (
<div className="absolute inset-0" style={{ filter: getFilterCss() }}> <div className="absolute inset-0" style={{ filter: getFilterCss() }}>
{mediaFile?.type.startsWith('video/') ? ( {mediaFile?.type.startsWith('video/') ? (
<video src={mediaPreview!} className="w-full h-full object-cover" autoPlay muted loop /> <div className="w-full h-full flex items-center justify-center" style={{ background: bgColor }}>
<video src={mediaPreview!} className="w-full h-full object-contain" autoPlay muted={isMuted} loop />
</div>
) : ( ) : (
<img src={croppedPreview || mediaPreview!} className="w-full h-full object-cover" alt="base" /> <img src={croppedPreview || mediaPreview!} className="w-full h-full object-cover" alt="base" />
)} )}
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Eye, Trash2, MoreHorizontal, Send, Smile, UserIcon } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Eye, Trash2, MoreHorizontal, Send, Smile, UserIcon, Volume2, VolumeX } from 'lucide-react';
import EmojiOnlyPicker from './EmojiOnlyPicker'; import EmojiOnlyPicker from './EmojiOnlyPicker';
import { useAuthStore } from '../../../auth/application/authStore'; import { useAuthStore } from '../../../auth/application/authStore';
import { StoryApi } from '../../infrastructure/storyApi'; import { StoryApi } from '../../infrastructure/storyApi';
@@ -68,8 +68,23 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
} : null; } : null;
const storyTypeStr = String(currentStory?.type || '').toLowerCase(); const storyTypeStr = String(currentStory?.type || '').toLowerCase();
const isVideo = storyTypeStr === 'video' || storyTypeStr === '2'; const isVideo = storyTypeStr === 'video' || storyTypeStr === '1';
const isImage = storyTypeStr === 'image' || storyTypeStr === '1'; const isImage = storyTypeStr === 'image' || storyTypeStr === '0';
// If author muted the video, we force mute it for everyone
const forceMute = currentStory?.isMuted || false;
const effectiveMuted = forceMute || isMuted;
const metadata = useMemo(() => {
if (!currentStory?.content) return null;
try {
const data = JSON.parse(currentStory.content);
if (data.stickers || data.textObjects) return data;
} catch {
return null;
}
return null;
}, [currentStory?.content]);
useEffect(() => { useEffect(() => {
if (currentUser?.user.id === user?.id) { if (currentUser?.user.id === user?.id) {
@@ -149,7 +164,13 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !isVideo || paused) return; if (!video || !isVideo) return;
if (paused) {
video.pause();
} else {
video.play().catch(console.error);
}
const interval = setInterval(() => { const interval = setInterval(() => {
if (video.duration) { if (video.duration) {
@@ -241,6 +262,9 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
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" 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"
> >
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&family=Playfair+Display:ital,wght@1,900&family=Fira+Code:wght@700&family=Archivo+Black&display=swap');
`}</style>
{/* Background Shell Decoration */} {/* Background Shell Decoration */}
<div className="fixed inset-0 pointer-events-none overflow-hidden -z-10 opacity-30"> <div className="fixed inset-0 pointer-events-none overflow-hidden -z-10 opacity-30">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-[120px]"></div> <div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-[120px]"></div>
@@ -309,6 +333,15 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
</div> </div>
<div className="flex items-center gap-2 pointer-events-auto"> <div className="flex items-center gap-2 pointer-events-auto">
{isVideo && !forceMute && (
<button
onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }}
className="p-2 text-white/60 hover:text-white transition-colors"
title={isMuted ? "Включить звук" : "Выключить звук"}
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
)}
{currentUser.user.id === user?.id && ( {currentUser.user.id === user?.id && (
<button onClick={handleDelete} className="p-2 text-white/40 hover:text-red-400 transition-colors"> <button onClick={handleDelete} className="p-2 text-white/40 hover:text-red-400 transition-colors">
<Trash2 size={20} /> <Trash2 size={20} />
@@ -320,32 +353,41 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
{/* Content Media */} {/* Content Media */}
<div className="relative flex-1 bg-surface-container-low overflow-hidden"> <div className="relative flex-1 bg-surface-container-low overflow-hidden">
{isVideo && currentStory.mediaUrl ? ( {isVideo && currentStory.mediaUrl ? (
<video <div className="w-full h-full flex items-center justify-center pointer-events-none" style={{ background: currentStory.bgColor || '#000' }}>
ref={videoRef} <video
src={getMediaUrl(currentStory.mediaUrl)} ref={videoRef}
className="w-full h-full object-cover pointer-events-none" src={getMediaUrl(currentStory.mediaUrl)}
autoPlay className="w-full h-full object-contain"
muted={isMuted} autoPlay
playsInline muted={effectiveMuted}
/> playsInline
) : isImage && currentStory.mediaUrl ? ( />
</div>
) : (
<img <img
src={getMediaUrl(currentStory.mediaUrl)} src={getMediaUrl(currentStory.mediaUrl!)}
alt="story" alt="story"
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105 pointer-events-none" className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105 pointer-events-none"
/> />
) : (
<div
className="w-full h-full flex items-center justify-center p-12 text-center"
style={{ background: currentStory.bgColor || '#1e1e2e' }}
>
<p className="text-white text-3xl font-black italic tracking-tighter leading-tight drop-shadow-2xl">
{currentStory.content || ''}
</p>
</div>
)} )}
{/* Legibility Gradient */} {/* Overlays Layer */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/40 pointer-events-none transition-opacity duration-300 group-hover:opacity-80" /> {metadata && (
<div className="absolute inset-0 pointer-events-none z-30">
{metadata.stickers?.map((s: any) => (
<div key={s.id} className="absolute" style={{ left: `${s.x}%`, top: `${s.y}%`, fontSize: `${s.scale * 40}px` }}>
{s.emoji}
</div>
))}
{metadata.textObjects?.map((t: any) => (
<div key={t.id} className="absolute" style={{ left: `${t.x}%`, top: `${t.y}%`, color: t.color, fontFamily: t.font, fontSize: `${t.size}px`, transform: `scale(${t.scale})`, whiteSpace: 'nowrap', fontWeight: 'bold' }}>
{t.text}
</div>
))}
</div>
)}
{/* Legibility Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/40 pointer-events-none transition-opacity duration-300 group-hover:opacity-80" />
</div> </div>
{/* Interactions Layer (Mobile-First Bottom) */} {/* Interactions Layer (Mobile-First Bottom) */}