Прокрутка дерганая
This commit is contained in:
@@ -10,6 +10,7 @@ public class UserProfileDto
|
||||
public string? Avatar { get; set; }
|
||||
public bool IsBot { get; set; }
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public DateTime? Birthday { get; set; }
|
||||
public bool IsPremium { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
|
||||
|
||||
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
||||
profile.About = request.Bio;
|
||||
profile.Birthday = request.Birthday;
|
||||
|
||||
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
||||
if (result.IsFailure)
|
||||
|
||||
@@ -83,7 +83,7 @@ internal class ProfileRepository : IProfileRepository
|
||||
profile.UpdateProfile(
|
||||
dto.DisplayName ?? profile.DisplayName,
|
||||
dto.About ?? profile.Bio,
|
||||
null);
|
||||
dto.Birthday);
|
||||
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, profile, new ReplaceOptions { IsUpsert = true }, ct);
|
||||
return Result.Success(profile.ToDto());
|
||||
|
||||
@@ -16,6 +16,7 @@ public static class ProfileMappings
|
||||
Avatar = document.AvatarUrl,
|
||||
IsBot = false,
|
||||
LastSeen = null,
|
||||
Birthday = document.Birthday,
|
||||
IsPremium = false,
|
||||
CreatedAt = document.CreatedAt
|
||||
};
|
||||
|
||||
@@ -23,6 +23,9 @@ const translations = {
|
||||
enterName: 'Введите имя',
|
||||
tellAboutYourself: 'Расскажите о себе',
|
||||
removePhoto: 'Удалить фото',
|
||||
cropPhoto: 'Обрезать фото',
|
||||
saveChanges: 'Сохранить изменения',
|
||||
displayName: 'Отображаемое имя',
|
||||
// Settings
|
||||
language: 'Язык / Language',
|
||||
interfaceLang: 'Язык интерфейса',
|
||||
@@ -330,6 +333,9 @@ const translations = {
|
||||
enterName: 'Enter name',
|
||||
tellAboutYourself: 'Tell about yourself',
|
||||
removePhoto: 'Remove photo',
|
||||
cropPhoto: 'Crop Photo',
|
||||
saveChanges: 'Save Changes',
|
||||
displayName: 'Display Name',
|
||||
language: 'Language',
|
||||
chats: 'Chats',
|
||||
calls: 'Calls',
|
||||
|
||||
@@ -44,7 +44,10 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto group cursor-pointer relative">
|
||||
<div
|
||||
className="mt-auto group cursor-pointer relative"
|
||||
onClick={() => onTabChange('settings')}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||
<img
|
||||
alt="User Profile"
|
||||
|
||||
@@ -14,6 +14,7 @@ import ChatView from './components/ChatView';
|
||||
import CallModal from '../../calls/presentation/components/CallModal';
|
||||
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
|
||||
import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar';
|
||||
import SettingsPage from '../../users/presentation/components/SettingsPage';
|
||||
|
||||
export default function ChatPage() {
|
||||
const {
|
||||
@@ -414,6 +415,8 @@ export default function ChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'settings' ? (
|
||||
<SettingsPage />
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-zinc-500">Coming soon</p>
|
||||
|
||||
@@ -89,8 +89,13 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
const proceedWithClick = () => {
|
||||
setShowAttachmentConfirm(false);
|
||||
(window as any).hasUnsavedAttachments = false;
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
|
||||
if (isActive) {
|
||||
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
|
||||
} else {
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
|
||||
@@ -117,6 +117,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
||||
const lastObservedMessageIdRef = useRef<string | null>(null);
|
||||
const initialScrollChatId = useRef<string | null>(null);
|
||||
const chatScrollPositionsRef = useRef<Record<string, number>>({});
|
||||
const visitedChatsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Load muted state
|
||||
useEffect(() => {
|
||||
@@ -177,75 +179,234 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
// Прокрутка вниз
|
||||
const scrollToBottom = useCallback((smooth = true) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
||||
} else if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки)
|
||||
useLayoutEffect(() => {
|
||||
setScrollReady(false);
|
||||
}, [activeChat]);
|
||||
const isInitializingRef = useRef(false);
|
||||
const isScrollingToBottomRef = useRef(false);
|
||||
const scrollTimeoutRef = useRef<any>(null);
|
||||
const prevChatIdRef = useRef<string | null>(activeChat);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isLoadingMessages && messagesContainerRef.current && activeChat !== initialScrollChatId.current) {
|
||||
initialScrollChatId.current = activeChat;
|
||||
const container = messagesContainerRef.current;
|
||||
const unreadId = sessionUnreadRef.current.msgId;
|
||||
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (СТРОГО ПО ID)
|
||||
const saveScrollPosition = useCallback((targetChatId?: string) => {
|
||||
const container = messagesContainerRef.current;
|
||||
const chatId = targetChatId || activeChat;
|
||||
|
||||
// НЕ сохраняем, если чат ещё не восстановил свою позицию или в процессе загрузки
|
||||
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||||
|
||||
if (unreadId && activeChat === sessionUnreadRef.current.chatId) {
|
||||
const dividerEl = document.getElementById('unread-divider');
|
||||
const unreadEl = document.getElementById(`msg-${unreadId}`);
|
||||
const targetEl = dividerEl || unreadEl;
|
||||
|
||||
if (targetEl) {
|
||||
// Вычитаем отступ сверху, чтобы начало непрочитанных было под шапкой
|
||||
container.scrollTop = targetEl.offsetTop - 60;
|
||||
} else {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
}, 100);
|
||||
// ГАРАНТИЯ: Если сообщения в стейте НЕ от этого чата - не пишем в память мусор
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
|
||||
|
||||
// КРИТИЧНО: Если этот чат уже помечен как находящийся внизу,
|
||||
// не позволяем автоматике перезаписать это якорем (защита "второго клика")
|
||||
if (localStorage.getItem(`chat_at_bottom_${chatId}`) === 'true') {
|
||||
const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
|
||||
if (isNearBottomNow) {
|
||||
localStorage.removeItem(`chat_anchor_${chatId}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
setScrollReady(true);
|
||||
}
|
||||
}, [activeChat, isLoadingMessages]);
|
||||
|
||||
// Scroll on new message arrivals
|
||||
useEffect(() => {
|
||||
if (chatMessages.length > 0) {
|
||||
const lastMsg = chatMessages[chatMessages.length - 1];
|
||||
const prevId = lastObservedMessageIdRef.current;
|
||||
lastObservedMessageIdRef.current = lastMsg.id;
|
||||
const messageElements = container.querySelectorAll('[data-message-id]');
|
||||
if (messageElements.length === 0) return;
|
||||
|
||||
// Scroll ONLY if a genuinely new message was added (not during initial chat load)
|
||||
if (prevId && prevId !== lastMsg.id) {
|
||||
if (lastMsg.senderId === user?.id) {
|
||||
setTimeout(() => scrollToBottom(true), 50);
|
||||
} else {
|
||||
// Если пользователь внизу — прокрутить
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
const isNearBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
if (isNearBottom) setTimeout(() => scrollToBottom(true), 50);
|
||||
}
|
||||
}
|
||||
let anchor = null;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
for (const el of messageElements) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top >= containerRect.top) {
|
||||
anchor = {
|
||||
id: el.getAttribute('data-message-id'),
|
||||
offset: rect.top - containerRect.top
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (anchor && anchor.id) {
|
||||
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 {
|
||||
lastObservedMessageIdRef.current = null;
|
||||
localStorage.removeItem(`chat_at_bottom_${chatId}`);
|
||||
}
|
||||
}, [chatMessages.length, user?.id, scrollToBottom]);
|
||||
}, [activeChat, scrollReady, chatMessages]);
|
||||
|
||||
// 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container || !activeChat) return false;
|
||||
|
||||
// КРИТИЧНО: Ждем, пока в хранилище сообщений появятся данные именно от активного чата
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chatMessages.length === 0) return true;
|
||||
|
||||
// Сначала проверяем, был ли пользователь внизу (защита "второго клика")
|
||||
if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') {
|
||||
scrollToBottom(false);
|
||||
|
||||
// Агрессивные повторы, если контент еще догружается (картинки и т.д.)
|
||||
for (const delay of [100, 300, 600, 1000]) {
|
||||
setTimeout(() => {
|
||||
if (activeChat === prevChatIdRef.current) scrollToBottom(false);
|
||||
}, delay);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Восстановление по якорю сообщения
|
||||
const saved = localStorage.getItem(`chat_anchor_${activeChat}`);
|
||||
if (saved) {
|
||||
try {
|
||||
const { id, offset } = JSON.parse(saved);
|
||||
let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement;
|
||||
if (!el) {
|
||||
const msgIndex = chatMessages.findIndex(m => m.id === id);
|
||||
if (msgIndex !== -1) {
|
||||
for (let i = msgIndex; i < chatMessages.length; i++) {
|
||||
const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement;
|
||||
if (nextEl) { el = nextEl; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (el) {
|
||||
container.scrollTop = el.offsetTop - offset;
|
||||
return true;
|
||||
}
|
||||
if (chatMessages.some(m => m.id === id)) return false;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
// Если ничего нет - к непрочитанным или вниз
|
||||
const firstUnread = chatMessages.find(m => m.senderId !== user?.id && !m.readBy?.some(r => r.userId === user?.id));
|
||||
if (firstUnread) {
|
||||
const el = document.getElementById(`msg-${firstUnread.id}`) || document.getElementById('unread-divider');
|
||||
if (el) {
|
||||
container.scrollTop = (el as HTMLElement).offsetTop - 80;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
scrollToBottom(false);
|
||||
return true;
|
||||
}, [activeChat, chatMessages, scrollToBottom, user?.id]);
|
||||
|
||||
// 3. ОБЗЕРВЕР И ИНИЦИАЛИЗАЦИЯ
|
||||
useEffect(() => {
|
||||
if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return;
|
||||
const container = messagesContainerRef.current;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
// Игнорируем замеры, пока мы не отпозиционировали чат изначально
|
||||
if (isInitializingRef.current) return;
|
||||
|
||||
if (!scrollReady) {
|
||||
if (restoreScrollPosition()) {
|
||||
setScrollReady(true);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
} else if (localStorage.getItem(`chat_at_bottom_${activeChat}`) === 'true') {
|
||||
const isNearBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
if (isNearBottomNow) {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const messagesDiv = container.querySelector('.space-y-1');
|
||||
if (messagesDiv) observer.observe(messagesDiv);
|
||||
|
||||
// Принудительно пробуем восстановить, если сообщения ПРАВИЛЬНЫЕ
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId === activeChat && !scrollReady) {
|
||||
if (restoreScrollPosition()) {
|
||||
setScrollReady(true);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [activeChat, isLoadingMessages, chatMessages, restoreScrollPosition, scrollReady, scrollToBottom]);
|
||||
|
||||
// 4. ПЕРЕКЛЮЧЕНИЕ ЧАТОВ (ФИКС RACE CONDITION)
|
||||
useLayoutEffect(() => {
|
||||
if (activeChat !== prevChatIdRef.current) {
|
||||
// СРАЗУ блокируем сохранение, чтобы handleScroll ничего не записал при изменении высоты
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// Сохраняем позицию ПРЕДЫДУЩЕГО чата ПЕРЕД установкой новых данных
|
||||
if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) {
|
||||
saveScrollPosition(prevChatIdRef.current);
|
||||
}
|
||||
|
||||
setScrollReady(false);
|
||||
prevChatIdRef.current = activeChat;
|
||||
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Таймер защиты на случай медленного рендера
|
||||
const timer = setTimeout(() => {
|
||||
isInitializingRef.current = false;
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [activeChat, saveScrollPosition, scrollReady]);
|
||||
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container || isInitializingRef.current) return;
|
||||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
setShowScrollDown(!isNearBottom);
|
||||
}, []);
|
||||
|
||||
const handleScroll = () => {
|
||||
// В период инициализации (1.2с) или принудительного скролла вниз - игнорируем любые события.
|
||||
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||||
|
||||
checkScrollPosition();
|
||||
const container = messagesContainerRef.current;
|
||||
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);
|
||||
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 200);
|
||||
|
||||
if (container.scrollTop < 100 && hasMoreMessages[activeChat] && !isLoadingMessages) {
|
||||
useChatStore.getState().loadMessages(activeChat, false, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScrollEvent = (e: any) => {
|
||||
if (e.detail?.chatId === activeChat) {
|
||||
isScrollingToBottomRef.current = true;
|
||||
localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true');
|
||||
localStorage.removeItem(`chat_anchor_${activeChat}`);
|
||||
scrollToBottom(true);
|
||||
setTimeout(() => {
|
||||
isScrollingToBottomRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
window.addEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent);
|
||||
return () => window.removeEventListener('CHAT_SCROLL_TO_BOTTOM', handleScrollEvent);
|
||||
}, [activeChat, scrollToBottom]);
|
||||
|
||||
// Read receipts using IntersectionObserver
|
||||
const sentReadIdsRef = useRef<Set<string>>(new Set());
|
||||
@@ -254,7 +415,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
useEffect(() => {
|
||||
if (!activeChat || !user?.id) return;
|
||||
|
||||
// Cleanup previous observer
|
||||
if (observerRef.current) observerRef.current.disconnect();
|
||||
sentReadIdsRef.current.clear();
|
||||
|
||||
@@ -286,7 +446,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
});
|
||||
|
||||
if (newlyReadIds.length > 0 && highestMsgId) {
|
||||
console.log('[IntersectionObserver] Marking as read up to:', highestSequenceId);
|
||||
socket.emit('read_messages', {
|
||||
chatId: activeChat,
|
||||
lastReadMessageId: highestMsgId,
|
||||
@@ -297,13 +456,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
},
|
||||
{
|
||||
root: messagesContainerRef.current,
|
||||
threshold: 0.1, // Message must be 10% visible
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
|
||||
observerRef.current = observer;
|
||||
|
||||
// Initial observation of unread messages
|
||||
const observeUnread = () => {
|
||||
if (!messagesContainerRef.current) return;
|
||||
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
||||
@@ -315,16 +473,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
});
|
||||
};
|
||||
|
||||
// Delay slight to let everything mount and be visible in DOM
|
||||
setTimeout(observeUnread, 100);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [activeChat, user?.id]);
|
||||
|
||||
// Re-run observation when messages change (to catch new arrivals)
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
if (!messagesContainerRef.current) return;
|
||||
if (observerRef.current && messagesContainerRef.current) {
|
||||
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
||||
unreadElements.forEach((el) => {
|
||||
const id = el.getAttribute('data-message-id');
|
||||
@@ -335,26 +489,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
}
|
||||
}, [chatMessages, scrollReady]);
|
||||
|
||||
// Scroll detection
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
const isNearBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
setShowScrollDown(!isNearBottom);
|
||||
}, []);
|
||||
|
||||
const handleScroll = () => {
|
||||
checkScrollPosition();
|
||||
|
||||
const container = messagesContainerRef.current;
|
||||
if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) {
|
||||
useChatStore.getState().loadMessages(activeChat, false, true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check scroll position when messages change or scroll ready state changes
|
||||
if (scrollReady) {
|
||||
checkScrollPosition();
|
||||
}
|
||||
@@ -998,23 +1133,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
<AnimatePresence>
|
||||
{showScrollDown && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
initial={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.5, opacity: 0, y: 20 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => {
|
||||
scrollToBottom(false);
|
||||
scrollToBottom(true);
|
||||
if (activeChat && unreadCount > 0) {
|
||||
useChatStore.getState().markAllAsRead(activeChat);
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-24 right-5 w-12 h-12 rounded-full bg-surface-secondary/95 backdrop-blur-md border border-border shadow-xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10"
|
||||
className="absolute bottom-24 right-8 w-14 h-14 rounded-2xl bg-gradient-to-br from-primary to-primary-container text-on-primary-container shadow-[0_8px_30px_rgba(48,150,229,0.3)] flex items-center justify-center transition-all z-10 border border-white/10 backdrop-blur-md"
|
||||
>
|
||||
<ArrowDown size={22} className="text-accent hover:text-accent-light transition-colors" />
|
||||
<span className="material-symbols-outlined text-3xl">arrow_downward</span>
|
||||
{unreadCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -top-1.5 -right-1.5 min-w-[20px] h-5 px-1.5 rounded-full bg-accent text-white text-[11px] font-bold flex items-center justify-center shadow-lg border-2 border-surface-secondary"
|
||||
className="absolute -top-2 -right-2 min-w-[24px] h-6 px-1.5 rounded-full bg-error text-on-error text-[12px] font-black flex items-center justify-center shadow-lg border-2 border-surface-container-lowest"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</motion.span>
|
||||
|
||||
@@ -12,7 +12,7 @@ export class UserApi {
|
||||
return httpClient.request<User>(`/profiles/${id}`);
|
||||
}
|
||||
|
||||
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
|
||||
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) {
|
||||
// Конечная точка в новом бэкенде: PUT /api/profiles/profile
|
||||
return httpClient.request<User>('/profiles/profile', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import Cropper, { Area, Point } from 'react-easy-crop';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Crop, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
|
||||
interface AvatarCropModalProps {
|
||||
image: string;
|
||||
onCrop: (cropData: Area) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AvatarCropModal({ image, onCrop, onClose }: AvatarCropModalProps) {
|
||||
const { t } = useLang();
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
if (croppedAreaPixels) {
|
||||
onCrop(croppedAreaPixels);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[10001] flex items-center justify-center p-4"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/90 backdrop-blur-xl" onClick={onClose} />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-[500px] aspect-square rounded-[2.5rem] bg-surface-container overflow-hidden border border-white/10 shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 inset-x-0 h-16 flex items-center justify-between px-6 z-50 bg-linear-to-b from-black/60 to-transparent pointer-events-none">
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em] text-white/70 pointer-events-auto">
|
||||
{t('cropPhoto') || 'ОБРЕЗКА ФОТО'}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-black/40 hover:bg-black/60 text-white/50 hover:text-white transition-all pointer-events-auto"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cropper Area */}
|
||||
<div className="flex-1 relative bg-[#0a0a0a]">
|
||||
<Cropper
|
||||
image={image}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
classes={{
|
||||
containerClassName: 'bg-[#0a0a0a]',
|
||||
cropAreaClassName: 'rounded-[3rem] border-2 border-white/50 shadow-[0_0_0_9999px_rgba(0,0,0,0.6)]'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="p-8 pb-10 bg-surface-container relative z-50">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<ZoomOut size={16} className="text-zinc-500" />
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<ZoomIn size={16} className="text-zinc-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => { setCrop({ x: 0, y: 0 }); setZoom(1); }}
|
||||
className="p-4 rounded-2xl bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10 transition-all"
|
||||
title="Сбросить"
|
||||
>
|
||||
<RotateCcw size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex-1 py-4 rounded-2xl bg-primary text-on-primary font-bold shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<Crop size={20} />
|
||||
{t('saveChanges') || 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
User, Camera, Edit3, Calendar, Info,
|
||||
MapPin, AtSign, Check, Loader2, LogOut,
|
||||
ChevronRight, ArrowLeft, Languages, Palette, Trash2,
|
||||
Bell, Shield, Eye
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { UserApi } from '../../infrastructure/userApi';
|
||||
import { getMediaUrl, getInitials } from '../../../../core/utils/utils';
|
||||
import DatePicker from '../../../../core/presentation/components/ui/DatePicker';
|
||||
import AvatarCropModal from './AvatarCropModal';
|
||||
import { Area } from 'react-easy-crop';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, updateUser, logout } = useAuthStore();
|
||||
const { t, lang, setLang } = useLang();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
displayName: user?.displayName || '',
|
||||
bio: user?.bio || '',
|
||||
birthday: user?.birthday || ''
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Avatar cropping state
|
||||
const [cropModal, setCropModal] = useState<{ open: boolean, image: string, file: File | null }>({ open: false, image: '', file: null });
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
displayName: user.displayName || '',
|
||||
bio: user.bio || '',
|
||||
birthday: user.birthday || ''
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
birthday: formData.birthday || null
|
||||
};
|
||||
const updatedUser = await UserApi.updateProfile(payload);
|
||||
updateUser(updatedUser);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setCropModal({ open: true, image: reader.result as string, file });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onCropConfirm = async (cropData: Area) => {
|
||||
if (!cropModal.file) return;
|
||||
setCropModal(prev => ({ ...prev, open: false }));
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
const updatedUser = await UserApi.cropAvatar(cropModal.file, {
|
||||
x: Math.round(cropData.x),
|
||||
y: Math.round(cropData.y),
|
||||
width: Math.round(cropData.width),
|
||||
height: Math.round(cropData.height)
|
||||
});
|
||||
updateUser(updatedUser);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAvatar = async () => {
|
||||
try {
|
||||
const updatedUser = await UserApi.removeAvatar();
|
||||
updateUser(updatedUser);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const initials = getInitials(user?.displayName || user?.username || '??');
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full overflow-y-auto px-12 py-16 custom-scrollbar bg-surface-container-lowest">
|
||||
<div className="max-w-[700px] mx-auto space-y-12">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-end justify-between border-b border-white/[0.04] pb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-headline font-black text-white mb-2 leading-none uppercase tracking-tighter">
|
||||
{t('settings') || 'Settings'}
|
||||
</h1>
|
||||
<p className="text-zinc-500 text-sm font-medium tracking-tight">Управляйте своим профилем и настройками приложения</p>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="px-6 py-2.5 rounded-2xl bg-white/5 border border-white/10 text-white text-xs font-black uppercase tracking-widest hover:bg-primary hover:text-on-primary hover:border-primary transition-all flex items-center gap-2 group"
|
||||
>
|
||||
<Edit3 size={14} className="group-hover:scale-110 transition-transform" />
|
||||
{t('edit') || 'Изменить'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="px-6 py-2.5 rounded-2xl text-on-surface-variant text-xs font-black uppercase tracking-widest hover:text-white transition-colors"
|
||||
>
|
||||
{t('cancel') || 'Отмена'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={saving}
|
||||
className="px-8 py-2.5 rounded-2xl bg-primary text-on-primary text-xs font-black uppercase tracking-widest shadow-lg shadow-primary/20 hover:scale-[1.03] active:scale-95 transition-all flex items-center gap-3"
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('save') || 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile/Avatar Section */}
|
||||
<div className="flex flex-col md:flex-row gap-12 items-start py-4">
|
||||
<div className="relative group">
|
||||
{/* Avatar Glow */}
|
||||
<div className={`absolute -inset-4 bg-primary/20 blur-3xl rounded-full opacity-40 group-hover:opacity-60 transition-opacity ${uploadingAvatar ? 'animate-pulse' : ''}`} />
|
||||
|
||||
<div className="relative w-48 h-48 rounded-[3rem] bg-zinc-900 border-2 border-white/10 p-1.5 overflow-hidden shadow-2xl transition-transform duration-500 group-hover:scale-[1.02]">
|
||||
<div className="relative w-full h-full rounded-[2.5rem] overflow-hidden bg-surface-container flex items-center justify-center">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={getMediaUrl(user.avatar)}
|
||||
className={`w-full h-full object-cover transition-opacity duration-500 ${uploadingAvatar ? 'opacity-40' : 'opacity-100'}`}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-linear-to-br from-primary/30 to-primary-container/10 flex items-center justify-center text-5xl font-black text-primary">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadingAvatar && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[2px]">
|
||||
<Loader2 size={32} className="text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-3 rounded-full bg-primary text-on-primary shadow-xl scale-90 group-hover:scale-100 transition-all"
|
||||
>
|
||||
<Camera size={24} />
|
||||
</button>
|
||||
{user?.avatar && (
|
||||
<button
|
||||
onClick={removeAvatar}
|
||||
className="p-2 text-white/40 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-8 w-full">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-on-surface-variant opacity-40">{t('displayName') || 'Имя профиля'}</label>
|
||||
{editing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={(e) => setFormData(p => ({ ...p, displayName: e.target.value }))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-lg font-bold text-white focus:border-primary/50 transition-all outline-none"
|
||||
placeholder="Как вас называть?"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-3xl font-black text-white tracking-tighter">{user?.displayName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-on-surface-variant opacity-40">
|
||||
<AtSign size={12} />
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em]">{t('username') || 'Username'}</label>
|
||||
</div>
|
||||
<p className="text-base font-bold text-zinc-400">@{user?.username || user?.userName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio & Details Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
||||
<div className="flex items-center gap-3 text-primary mb-1">
|
||||
<Info size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('about') || 'О себе'}</span>
|
||||
</div>
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={formData.bio}
|
||||
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value }))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
|
||||
placeholder="Расскажите немного о себе..."
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base text-white/70 leading-relaxed font-medium min-h-[4rem]">
|
||||
{user?.bio || 'Информация не указана'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
||||
<div className="flex items-center gap-3 text-primary mb-1">
|
||||
<Calendar size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('birthday') || 'Дата рождения'}</span>
|
||||
</div>
|
||||
{editing ? (
|
||||
<DatePicker
|
||||
value={formData.birthday}
|
||||
onChange={(val) => setFormData(p => ({ ...p, birthday: val }))}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base text-white/70 leading-relaxed font-black uppercase tracking-widest">
|
||||
{user?.birthday ? new Date(user.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Не указана'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Settings */}
|
||||
<div className="space-y-6 pt-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-px h-6 bg-primary/40 rounded-full" />
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-white/40">Настройки приложения</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Language Picker */}
|
||||
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 space-y-4">
|
||||
<div className="flex items-center gap-3 opacity-60">
|
||||
<Languages size={16} className="text-zinc-500" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">{t('language') || 'Language'}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 p-1.5 rounded-2xl bg-black/20 w-fit">
|
||||
<button
|
||||
onClick={() => setLang('ru')}
|
||||
className={`px-4 py-2 rounded-[14px] text-[11px] font-black uppercase tracking-widest transition-all ${lang === 'ru' ? 'bg-primary text-on-primary shadow-lg shadow-primary/20' : 'text-zinc-500 hover:text-white'}`}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang('en')}
|
||||
className={`px-4 py-2 rounded-[14px] text-[11px] font-black uppercase tracking-widest transition-all ${lang === 'en' ? 'bg-primary text-on-primary shadow-lg shadow-primary/20' : 'text-zinc-500 hover:text-white'}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
|
||||
<button
|
||||
onClick={() => { logout(); }}
|
||||
className="w-full flex items-center justify-between p-4 rounded-2xl bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center text-red-500 group-hover:scale-105 transition-transform">
|
||||
<LogOut size={16} />
|
||||
</div>
|
||||
<span className="text-[11px] font-black uppercase tracking-widest text-red-500/70 group-hover:text-red-500 transition-colors">{t('logout') || 'Выйти'}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-red-500/40" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{cropModal.open && (
|
||||
<AvatarCropModal
|
||||
image={cropModal.image}
|
||||
onCrop={onCropConfirm}
|
||||
onClose={() => setCropModal({ open: false, image: '', file: null })}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user