Фиксы по историям
This commit is contained in:
+4
-3
@@ -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)
|
||||||
|
|||||||
+1
@@ -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)
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,26 +191,23 @@ 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]');
|
||||||
if (messageElements.length === 0) return;
|
if (messageElements.length === 0) return;
|
||||||
@@ -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,15 +317,15 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -357,10 +337,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
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());
|
||||||
|
|||||||
@@ -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,6 +165,22 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
|
const isVideo = mediaFile?.type.startsWith('video/');
|
||||||
|
let url = '';
|
||||||
|
let type = isVideo ? 'video' : 'image';
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
if (isVideo && mediaFile) {
|
||||||
|
// Handle Video: upload original file
|
||||||
|
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');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = TARGET_W;
|
canvas.width = TARGET_W;
|
||||||
canvas.height = TARGET_H;
|
canvas.height = TARGET_H;
|
||||||
@@ -215,15 +232,19 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fileToUpload = new File([blob], `story_${Date.now()}.jpg`, { type: 'image/jpeg' });
|
const fileToUpload = new File([blob], `story_${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||||||
const { url } = await ChatApi.uploadFile(fileToUpload);
|
const uploadRes = await ChatApi.uploadFile(fileToUpload);
|
||||||
|
url = uploadRes.url;
|
||||||
|
}
|
||||||
|
|
||||||
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,30 +353,39 @@ 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 ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center pointer-events-none" style={{ background: currentStory.bgColor || '#000' }}>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={getMediaUrl(currentStory.mediaUrl)}
|
src={getMediaUrl(currentStory.mediaUrl)}
|
||||||
className="w-full h-full object-cover pointer-events-none"
|
className="w-full h-full object-contain"
|
||||||
autoPlay
|
autoPlay
|
||||||
muted={isMuted}
|
muted={effectiveMuted}
|
||||||
playsInline
|
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
|
{/* Overlays Layer */}
|
||||||
className="w-full h-full flex items-center justify-center p-12 text-center"
|
{metadata && (
|
||||||
style={{ background: currentStory.bgColor || '#1e1e2e' }}
|
<div className="absolute inset-0 pointer-events-none z-30">
|
||||||
>
|
{metadata.stickers?.map((s: any) => (
|
||||||
<p className="text-white text-3xl font-black italic tracking-tighter leading-tight drop-shadow-2xl">
|
<div key={s.id} className="absolute" style={{ left: `${s.x}%`, top: `${s.y}%`, fontSize: `${s.scale * 40}px` }}>
|
||||||
{currentStory.content || ''}
|
{s.emoji}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Legibility Gradient */}
|
{/* 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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user