diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 0ee9eca..8bfc170 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -19,4 +19,5 @@ When working with this project, follow these rules: make it async. The import is `/frontend/src/utils/utils`. - When you complete your task, remove unused imports if there are any. - Follow DRY, SOLID, YAGNI and KISS principles. -- Do NOT use old, outdated or deprecated APIs and functions. \ No newline at end of file +- Do NOT use old, outdated or deprecated APIs and functions. +- Don't talk like a robot. Behave more like a human. \ No newline at end of file diff --git a/frontend/src/pages/app/resources/css/_chat.scss b/frontend/src/pages/app/resources/css/_chat.scss index cf26163..23dfe5a 100644 --- a/frontend/src/pages/app/resources/css/_chat.scss +++ b/frontend/src/pages/app/resources/css/_chat.scss @@ -77,6 +77,7 @@ display: flex; width: 100%; flex-direction: column; + overflow: hidden; .chat-main { flex-grow: 1; @@ -84,6 +85,7 @@ flex-direction: column; height: 100%; position: relative; + overflow: hidden; .chat-header { padding: 16px; @@ -536,11 +538,31 @@ flex: 1; display: flex; flex-direction: row; + align-items: center; + + .buttons, .left-buttons { + display: flex; + flex-direction: row; + align-items: center; + } + + .left-buttons { + .emoji-btn { + margin: 10px; + color: $color-dark-on-surface-variant; + transition: color 0.2s ease; + flex-shrink: 0; + align-self: flex-end; + + &:hover { + color: $color-dark-primary; + } + } + } .message-input { flex: 1; - padding: 20px 20px; - padding-right: 0; + padding: 20px 0; border: none; border-radius: 25px; font-size: 1rem; @@ -561,11 +583,6 @@ } .buttons { - align-self: flex-end; - display: flex; - flex-direction: row; - align-items: center; - .send-btn { margin: 10px; width: 50px; @@ -762,4 +779,193 @@ align-items: center; justify-content: center; } -} \ No newline at end of file +} + +// Emoji Menu Styles +.emoji-menu { + $transition: cubic-bezier(0.4, 0, 0.2, 1); + + background: $color-dark-surface-container; + border: 1px solid rgba($color-dark-outline-variant, 0.4); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(20px); + width: 320px; + height: 400px; + overflow: hidden; + display: flex; + flex-direction: column; + transform-origin: bottom left; + opacity: 0; + transform: translateY(30px); + transition: transform 0.25s $transition, opacity 0.25s $transition; + user-select: none; + + &.open { + opacity: 1; + transform: translateY(0); + } + + .emoji-menu-header { + background: $color-dark-surface-container-high; + border-bottom: 1px solid rgba($color-dark-outline-variant, 0.2); + position: sticky; + top: 0; + z-index: 1; + + .emoji-category-tabs { + display: flex; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; + padding: 8px; + + &::-webkit-scrollbar { + height: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: $color-dark-surface-container; + border-radius: 2px; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: $color-dark-surface-container-high; + } + + .emoji-category-tab { + background: transparent; + border: none; + border-radius: 10px; + padding: 8px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-size: 1.2rem; + min-width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + + &:hover { + background: $color-dark-surface-container; + } + + &.active { + background: $color-dark-primary-container; + color: $color-dark-on-primary-container; + transform: scale(1.05); + } + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $color-dark-primary-container; + opacity: 0; + transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 8px; + } + + &.active::before { + opacity: 1; + } + + span { + position: relative; + z-index: 1; + } + } + } + } + + .emoji-grid { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + scroll-behavior: smooth; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: $color-dark-surface-container-high; + border-radius: 3px; + } + + .emoji-category-section { + .emoji-category-title { + position: sticky; + top: 0; + padding-top: 5px; + padding-bottom: 5px; + padding-left: 12px; + padding-right: 12px; + font-size: 0.85rem; + font-weight: 600; + color: $color-dark-on-surface-variant; + z-index: 2; + margin: 0; + backdrop-filter: blur(10px); + } + + .emoji-category-grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 2px; + padding: 8px; + } + } + + .emoji-item { + $size: 30px; + + background: transparent; + border: none; + border-radius: 6px; + padding: 5px; + cursor: pointer; + transition: all 0.15s ease; + font-size: $size; + width: $size; + height: $size; + box-sizing: content-box; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: $color-dark-surface-container-high; + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } + } + + .emoji-empty-state { + padding: 20px; + text-align: center; + color: $color-dark-on-surface-variant; + font-size: 0.9rem; + } +} diff --git a/frontend/src/pages/app/ui/components/chat/ChatHeader.tsx b/frontend/src/pages/app/ui/components/chat/ChatHeader.tsx index 4f1c236..2c47b88 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatHeader.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatHeader.tsx @@ -1,5 +1,5 @@ import { PRODUCT_NAME } from "../../../core/config"; -import { useProfile } from "../../hooks/useProfile"; +import useProfile from "../../hooks/useProfile"; import defaultAvatar from "../../../resources/images/default-avatar.png"; import { useState } from "react"; import { ProfileDialog } from "../profile/ProfileDialog"; diff --git a/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx index e308f98..502d932 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { MaterialDialog } from "../core/Dialog"; import { RichTextArea } from "../core/RichTextArea"; import type { Message } from "../../../core/types"; import Quote from "../core/Quote"; import AnimatedHeight from "../core/animations/AnimatedHeight"; import { useImmer } from "use-immer"; +import { EmojiMenu } from "./EmojiMenu"; interface ChatInputWrapperProps { onSendMessage: (message: string, files: File[]) => void; @@ -18,6 +19,7 @@ interface ChatInputWrapperProps { onClearEdit?: () => void; onCloseEdit?: () => void; onProvideFileAdder?: (adder: (files: File[]) => void) => void; + messagePanelRef?: React.RefObject; } export function ChatInputWrapper( @@ -32,13 +34,17 @@ export function ChatInputWrapper( editVisible = false, onClearEdit, onCloseEdit, - onProvideFileAdder + onProvideFileAdder, + messagePanelRef }: ChatInputWrapperProps ) { const [message, setMessage] = useState(""); const [selectedFiles, setSelectedFiles] = useImmer([]); const [attachmentsVisible, setAttachmentsVisible] = useState(false); const [errorOpen, setErrorOpen] = useState(false); + const [emojiMenuOpen, setEmojiMenuOpen] = useState(false); + const [emojiMenuPosition, setEmojiMenuPosition] = useState({ x: 0, y: 0 }); + const chatInputWrapperRef = useRef(null); // Expose a way for parent to programmatically add files useEffect(() => { @@ -60,7 +66,26 @@ export function ChatInputWrapper( setAttachmentsVisible(selectedFiles.length > 0); }, [selectedFiles]); - const handleSubmit = async (e: React.FormEvent | Event) => { + function handleEmojiButtonClick() { + if (chatInputWrapperRef.current && messagePanelRef?.current) { + const inputRect = chatInputWrapperRef.current.getBoundingClientRect(); + const panelRect = messagePanelRef.current.getBoundingClientRect(); + + // Position menu 10px from message panel edge and 10px above the chat input + // The animation will start 30px below this position + setEmojiMenuPosition({ + x: panelRect.left + 10, // 10px from message panel edge + y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input + }); + setEmojiMenuOpen(true); + } + }; + + function handleEmojiSelect(emoji: string) { + setMessage(prev => prev + emoji); + }; + + async function handleSubmit(e: React.FormEvent | Event) { e.preventDefault(); const hasText = Boolean(message.trim()); const hasFiles = selectedFiles.length > 0; @@ -95,7 +120,7 @@ export function ChatInputWrapper( } return ( -
+
{editingMessage && ( @@ -150,6 +175,9 @@ export function ChatInputWrapper( )}
+
+ +
Общий размер вложений превышает 4 ГБ.
setErrorOpen(false)}>Закрыть + + setEmojiMenuOpen(false)} + onEmojiSelect={handleEmojiSelect} + position={emojiMenuPosition} + />
); } diff --git a/frontend/src/pages/app/ui/components/chat/ChatMainHeader.tsx b/frontend/src/pages/app/ui/components/chat/ChatMainHeader.tsx index b32b876..19720bb 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatMainHeader.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatMainHeader.tsx @@ -1,8 +1,8 @@ -import { useChat } from "../../hooks/useChat"; +import { useAppState } from "../../state"; import defaultAvatar from "../../../resources/images/default-avatar.png"; export function ChatMainHeader() { - const { currentChat } = useChat(); + const { currentChat } = useAppState().chat; return (
diff --git a/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx b/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx index 94c4ee8..9dcb11e 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx @@ -1,4 +1,3 @@ -import { useChat } from "../../hooks/useChat"; import { Message } from "./Message"; import { useAppState } from "../../state"; import type { Message as MessageType } from "../../../core/types"; @@ -21,12 +20,10 @@ interface ChatMessagesProps { dmRecipientPublicKey?: string; } -export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) { - const { messages: hookMessages } = useChat(); +export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) { const { user } = useAppState(); - // Use prop messages if provided, otherwise use hook messages - const messages = propMessages || hookMessages; + // Use prop messages (panels provide their own messages) const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [selectedUserProfile, setSelectedUserProfile] = useState(null); const [isLoadingProfile, setIsLoadingProfile] = useState(false); @@ -127,7 +124,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o return ( <>
- {messages.map((message) => ( + {messages.map((message: MessageType) => ( { - setCurrentChat(chatName); - }; + const { chat, setActiveTab, switchToPublicChat } = useAppState(); return (
- setActiveTab(e.detail.value)}> + setActiveTab(e.detail.value)}> Чаты @@ -29,7 +25,7 @@ export function ChatTabs() { headline="Общий чат" description="Вы: Последнее сообщение" id="chat-list-chat-1" - onClick={() => handleChatClick("Общий чат")} + onClick={async () => await switchToPublicChat("Общий чат")} style={{ cursor: "pointer" }} > @@ -38,7 +34,7 @@ export function ChatTabs() { headline="Общий чат 2" description="Вы: Последнее сообщение" id="chat-list-chat-2" - onClick={() => handleChatClick("Общий чат 2")} + onClick={async () => await switchToPublicChat("Общий чат 2")} style={{ cursor: "pointer" }} > diff --git a/frontend/src/pages/app/ui/components/chat/DMUsersList.tsx b/frontend/src/pages/app/ui/components/chat/DMUsersList.tsx index cd2b1a5..c09bc25 100644 --- a/frontend/src/pages/app/ui/components/chat/DMUsersList.tsx +++ b/frontend/src/pages/app/ui/components/chat/DMUsersList.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useDM } from "../../hooks/useDM"; +import { useDM, type DMUser } from "../../hooks/useDM"; import { useAppState } from "../../state"; import { fetchUserPublicKey } from "../../../api/dmApi"; import defaultAvatar from "../../../resources/images/default-avatar.png"; @@ -34,7 +34,7 @@ export function DMUsersList() { ); } - const handleUserClick = async (user: any) => { + async function handleUserClick(user: DMUser) { if (!user.publicKey) { // Get public key if not already loaded const authToken = useAppState.getState().user.authToken; @@ -60,7 +60,7 @@ export function DMUsersList() { return ( - {dmUsers.map((user) => ( + {dmUsers.map((user: DMUser) => ( void; + onEmojiSelect: (emoji: string) => void; + position: Size2D; +} + +export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMenuProps) { + const [activeCategory, setActiveCategory] = useState("recent"); + const [recentEmojis, setRecentEmojis] = useState([]); + const menuRef = useRef(null); + const scrollRef = useRef(null); + const categoryRefs = useRef>(new Map()); + const tabsRef = useRef(null); + const tabRefs = useRef>(new Map()); + + useEffect(() => { + if (isOpen) { + setRecentEmojis(getRecentEmojis()); + } + }, [isOpen]); + + const handleScroll = useCallback(() => { + if (!scrollRef.current) return; + + // Find which category is currently visible + for (const [categoryName, element] of categoryRefs.current) { + if (element) { + const rect = element.getBoundingClientRect(); + const containerRect = scrollRef.current.getBoundingClientRect(); + + // Check if category header is in view + if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) { + if (activeCategory !== categoryName) { + setActiveCategory(categoryName); + scrollTabIntoView(categoryName); + } + break; + } + } + } + }, [activeCategory]); + + function scrollToCategory(categoryName: string) { + const element = categoryRefs.current.get(categoryName); + if (element && scrollRef.current) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + } + + function scrollTabIntoView(categoryName: string) { + const tabElement = tabRefs.current.get(categoryName); + if (tabElement && tabsRef.current) { + const tabsRect = tabsRef.current.getBoundingClientRect(); + const tabRect = tabElement.getBoundingClientRect(); + + // Check if tab is outside the visible area + if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) { + tabElement.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center' + }); + } + } + } + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose(); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onClose]); + + function handleEmojiClick(emoji: string) { + addRecentEmoji(emoji); + onEmojiSelect(emoji); + onClose(); + }; + + + return ( +
+
+
+ {EMOJI_CATEGORIES.map((category) => ( + + ))} +
+
+ +
+ {EMOJI_CATEGORIES.map((category) => { + const emojis = category.name === "recent" ? recentEmojis : category.emojis; + + return ( +
{ + if (el) categoryRefs.current.set(category.name, el); + }} + className="emoji-category-section" + > +

+ {category.name.charAt(0).toUpperCase() + category.name.slice(1)} +

+ {emojis.length > 0 ? ( +
+ {emojis.map((emoji, index) => ( + + ))} +
+ ) : ( +
+ No {category.name} emojis +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/pages/app/ui/components/chat/LeftPanel.tsx b/frontend/src/pages/app/ui/components/chat/LeftPanel.tsx index 86a92b4..1377632 100644 --- a/frontend/src/pages/app/ui/components/chat/LeftPanel.tsx +++ b/frontend/src/pages/app/ui/components/chat/LeftPanel.tsx @@ -37,17 +37,16 @@ function BottomAppBar() { function ChatTabs() { - const { chat, switchToTab, switchToPublicChat } = useAppState(); + const { chat, setActiveTab, switchToPublicChat } = useAppState(); const { activeTab } = chat; - const handleChatClick = async (chatName: string) => { + async function handleChatClick(chatName: string) { await switchToPublicChat(chatName); - }; + } - const handleTabChange = async (e: FormEvent) => { - const tab = (e.target as Tabs).value as ChatTabs; - await switchToTab(tab); - }; + function handleTabChange(e: FormEvent) { + setActiveTab((e.target as Tabs).value as ChatTabs); + } return (
diff --git a/frontend/src/pages/app/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/pages/app/ui/components/chat/MessagePanelRenderer.tsx index 260d0c0..97ddc6e 100644 --- a/frontend/src/pages/app/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/pages/app/ui/components/chat/MessagePanelRenderer.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from "react"; +import { useAppState } from "../../state"; import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel"; import { ChatMessages } from "./ChatMessages"; import { ChatInputWrapper } from "./ChatInputWrapper"; @@ -10,10 +11,11 @@ import type { DMPanel } from "../../panels/DMPanel"; interface MessagePanelRendererProps { panel: MessagePanel | null; - isChatSwitching: boolean; } -export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRendererProps) { +export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { + const { applyPendingPanel, chat } = useAppState(); + const messagePanelRef = useRef(null); const [panelState, setPanelState] = useState(null); const [switchIn, setSwitchIn] = useState(false); const [switchOut, setSwitchOut] = useState(false); @@ -24,6 +26,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen const [editVisible, setEditVisible] = useState(Boolean(editMessage)); const [pendingAction, setPendingAction] = useState(null); + // Drag & drop const [isDragging, setIsDragging] = useState(false); const dragCounterRef = useRef(0); @@ -55,31 +58,26 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen if (panel) { setPanelState(panel.getState()); - // Set up state change listener - const handleStateChange = (newState: MessagePanelState) => { + // Store the handler for cleanup + panel.onStateChange = (newState: MessagePanelState) => { setPanelState(newState); }; - // Store the handler for cleanup - panel.onStateChange = handleStateChange; - // Set up WebSocket message handler for this panel if (panel.handleWebSocketMessage) { setGlobalMessageHandler(panel.handleWebSocketMessage); } } else { setPanelState(null); - // Clear global message handler when no panel is active setGlobalMessageHandler(null); } - // Cleanup function return () => { if (panel) { if (panel.onStateChange) { panel.onStateChange = null; } - // Call destroy to clean up pending timeouts + if (typeof panel.destroy === 'function') { panel.destroy(); } @@ -87,67 +85,70 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen }; }, [panel]); - // Handle chat switching animation + // Handle chat switching animation with event listeners useEffect(() => { - console.log("🎬 [DEBUG] MessagePanelRenderer: isChatSwitching changed to:", isChatSwitching); - if (isChatSwitching) { - console.log("🎬 [DEBUG] MessagePanelRenderer: Starting switch-out animation"); + if (chat.isSwitching) { setSwitchOut(true); - setTimeout(() => { - console.log("🎬 [DEBUG] MessagePanelRenderer: Starting switch-in animation"); - setSwitchOut(false); - setSwitchIn(true); - setTimeout(() => { - console.log("🎬 [DEBUG] MessagePanelRenderer: Ending switch-in animation"); + + // Use animation event listeners instead of hardcoded delays + function handleAnimationEnd(event: Event) { + const animationEvent = event as AnimationEvent; + + if (animationEvent.animationName === 'fadeOutUp') { + // Apply pending panel exactly at the boundary between animations + applyPendingPanel(); + setSwitchOut(false); + setSwitchIn(true); + } else if (animationEvent.animationName === 'fadeInDown') { setSwitchIn(false); - }, 200); - }, 250); + // End the chat switching state + chat.setIsSwitching(false); + } + }; + + // Add event listener to document to catch all animation events + document.addEventListener('animationend', handleAnimationEnd); + + // Cleanup function + return () => { + document.removeEventListener('animationend', handleAnimationEnd); + }; } - }, [isChatSwitching]); + }, [chat.isSwitching]); - // Scroll to bottom when messages change + // Load messages when panel changes and animation is not running useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [panelState?.messages]); + if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return; + + const panelState = chat.activePanel.getState(); + + if (panelState.messages.length === 0 && !panelState.isLoading) { + chat.activePanel.loadMessages(); + } + }, [chat.activePanel, chat.isSwitching, switchOut, switchIn]); - if (!panel || !panelState) { - return ( -
-
-
- Avatar -
-
-

Выбор чата

-

- - Выберите чат, чтобы начать переписку -

-
-
-
-
-
- Выберите чат на боковой панели, чтобы начать переписку -
-
-
-
- ); - } + // Scroll to bottom when messages change, but only when no animation is running + useEffect(() => { + if (!panelState || chat.isSwitching || switchOut || switchIn || panelState.isLoading) return; + + const el = messagesEndRef.current; + if (!el) return; + + // Defer to next frame to ensure layout is stable + const id = requestAnimationFrame(() => { + el.scrollIntoView({ behavior: "smooth", block: "end" }); + }); + + return () => cancelAnimationFrame(id); + }, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]); return (
{ + onDragEnter={panel ? (e) => { if (!e.dataTransfer) return; e.preventDefault(); e.stopPropagation(); @@ -155,20 +156,20 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen // Only show overlay when actual files are dragged const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files"); if (hasFiles) setIsDragging(true); - }} - onDragOver={(e) => { + } : undefined} + onDragOver={panel ? (e) => { if (!e.dataTransfer) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "copy"; - }} - onDragLeave={(e) => { + } : undefined} + onDragLeave={panel ? (e) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); if (dragCounterRef.current === 0) setIsDragging(false); - }} - onDrop={(e) => { + } : undefined} + onDrop={panel ? (e) => { if (!e.dataTransfer) return; e.preventDefault(); e.stopPropagation(); @@ -178,28 +179,34 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen } setIsDragging(false); dragCounterRef.current = 0; - }}> + } : undefined}>
Avatar
-

{panelState.title}

+

{panelState?.title || "Выбор чата"}

- - {panelState.online ? "Online" : "Offline"} - {panelState.isTyping && " • Typing..."} + + {panelState ? ( + <> + {panelState.online ? "Online" : "Offline"} + {panelState.isTyping && " • Typing..."} + + ) : ( + "Выберите чат, чтобы начать переписку" + )}

- {panelState.isLoading ? ( + {panelState?.isLoading ? (
- ): ( + ) : panelState && panel ? (
- )} - - e.preventDefault()} - onDrop={(e) => e.preventDefault()}> -
-
- - Отпустите файл(ы) для добавления + ) : ( +
+
+ Выберите чат на боковой панели, чтобы начать переписку
- - - { - panel.handleSendMessage(text, replyTo?.id, files); - setReplyTo(null); - }} - onSaveEdit={(content) => { - if (editMessage) { - panel.handleEditMessage(editMessage.id, content); - setEditMessage(null); - } - }} - replyTo={replyTo} - replyToVisible={replyToVisible} - onClearReply={() => { - setPendingAction(null); - setReplyToVisible(false); - }} - onCloseReply={() => { - setReplyTo(null); - if (pendingAction && pendingAction.type === "edit") { - setEditMessage(pendingAction.message); - setPendingAction(null); - } - }} - editingMessage={editMessage} - editVisible={editVisible} - onClearEdit={() => { - setPendingAction(null); - setEditVisible(false); - }} - onCloseEdit={() => { - setEditMessage(null); - if (pendingAction && pendingAction.type === "reply") { - setReplyTo(pendingAction.message); - setPendingAction(null); - } - }} - onProvideFileAdder={(adder) => { addFilesRef.current = adder; }} - /> + )} + + {panel && ( + <> + e.preventDefault()} + onDrop={(e) => e.preventDefault()}> +
+
+ + Отпустите файл(ы) для добавления +
+
+
+ + { + panel.handleSendMessage(text, replyTo?.id, files); + setReplyTo(null); + }} + onSaveEdit={(content) => { + if (editMessage) { + panel.handleEditMessage(editMessage.id, content); + setEditMessage(null); + } + }} + replyTo={replyTo} + replyToVisible={replyToVisible} + onClearReply={() => { + setPendingAction(null); + setReplyToVisible(false); + }} + onCloseReply={() => { + setReplyTo(null); + if (pendingAction && pendingAction.type === "edit") { + setEditMessage(pendingAction.message); + setPendingAction(null); + } + }} + editingMessage={editMessage} + editVisible={editVisible} + onClearEdit={() => { + setPendingAction(null); + setEditVisible(false); + }} + onCloseEdit={() => { + setEditMessage(null); + if (pendingAction && pendingAction.type === "reply") { + setReplyTo(pendingAction.message); + setPendingAction(null); + } + }} + onProvideFileAdder={(adder) => { addFilesRef.current = adder; }} + messagePanelRef={messagePanelRef} + /> + + )}
); diff --git a/frontend/src/pages/app/ui/components/chat/RightPanel.tsx b/frontend/src/pages/app/ui/components/chat/RightPanel.tsx index ece779f..05c0d46 100644 --- a/frontend/src/pages/app/ui/components/chat/RightPanel.tsx +++ b/frontend/src/pages/app/ui/components/chat/RightPanel.tsx @@ -3,11 +3,6 @@ import { MessagePanelRenderer } from "./MessagePanelRenderer"; export function RightPanel() { const { chat } = useAppState(); - - return ( - - ); -} + + return +} \ No newline at end of file diff --git a/frontend/src/pages/app/ui/components/chat/emojiData.ts b/frontend/src/pages/app/ui/components/chat/emojiData.ts new file mode 100644 index 0000000..d5cdbba --- /dev/null +++ b/frontend/src/pages/app/ui/components/chat/emojiData.ts @@ -0,0 +1,100 @@ +export interface EmojiCategory { + name: string; + icon: string; + emojis: string[]; +} + +export const EMOJI_CATEGORIES: EmojiCategory[] = [ + { + name: "recent", + icon: "🕒", + emojis: [] + }, + { + name: "smileys", + icon: "😀", + emojis: [ + "😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖", "😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾" + ] + }, + { + name: "people", + icon: "👋", + emojis: [ + "👋", "🤚", "🖐", "✋", "🖖", "👌", "🤏", "✌️", "🤞", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "👍", "👎", "👊", "✊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦿", "🦵", "🦶", "👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁", "👅", "👄", "💋", "🩸", "👶", "🧒", "👦", "👧", "🧑", "👨", "👩", "🧓", "👴", "👵", "👱", "🧔", "👲", "🧕", "👳", "👮", "👷", "💂", "🕵️", "👩‍⚕️", "👨‍⚕️", "👩‍🌾", "👨‍🌾", "👩‍🍳", "👨‍🍳", "👩‍🎓", "👨‍🎓", "👩‍🎤", "👨‍🎤", "👩‍🏫", "👨‍🏫", "👩‍🏭", "👨‍🏭", "👩‍💻", "👨‍💻", "👩‍💼", "👨‍💼", "👩‍🔧", "👨‍🔧", "👩‍🔬", "👨‍🔬", "👩‍🎨", "👨‍🎨", "👩‍🚒", "👨‍🚒", "👩‍✈️", "👨‍✈️", "👩‍🚀", "👨‍🚀", "👩‍⚖️", "👨‍⚖️", "👰", "🤵", "👸", "🤴", "🦸", "🦹", "🤶", "🎅", "🧙", "🧚", "🧛", "🧜", "🧝", "🧞", "🧟", "💆", "💇", "🚶", "🏃", "💃", "🕺", "🕴", "👯", "🧘", "🛀", "🛌", "👭", "👫", "👬", "💏", "💑", "👪" + ] + }, + { + name: "animals", + icon: "🐶", + emojis: [ + "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐽", "🐸", "🐵", "🙈", "🙉", "🙊", "🐒", "🐔", "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐜", "🦟", "🦗", "🕷", "🕸", "🦂", "🐢", "🐍", "🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍", "🦧", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌", "🐕", "🐩", "🦮", "🐕‍🦺", "🐈", "🐓", "🦃", "🦚", "🦜", "🦢", "🦩", "🕊", "🐇", "🦝", "🦨", "🦡", "🦦", "🦥", "🐁", "🐀", "🐿", "🦔" + ] + }, + { + name: "food", + icon: "🍎", + emojis: [ + "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔", "🍟", "🍕", "🫓", "🥙", "🌮", "🌯", "🫔", "🥗", "🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟", "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡", "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬", "🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "☕", "🫖", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹", "🧉", "🍾" + ] + }, + { + name: "travel", + icon: "🚗", + emojis: [ + "🚗", "🚕", "🚙", "🚌", "🚎", "🏎", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🏍", "🛵", "🚲", "🛴", "🛹", "🛼", "🚁", "✈️", "🛩", "🛫", "🛬", "🪂", "💺", "🚀", "🛸", "🚉", "🚊", "🚝", "🚞", "🚋", "🚃", "🚋", "🚋", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🎢", "🎡", "🎠", "⛵", "🛥", "🚤", "⛴", "🛳", "🚢", "⚓", "🚧", "⛽", "🚨", "🚥", "🚦", "🛑", "🚏", "🗺", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟", "🎡", "🎢", "🎠", "⛲", "⛱", "🏖", "🏝", "🏔", "⛰", "🌋", "🗻", "🏕", "⛺", "🏠", "🏡", "🏘", "🏚", "🏗", "🏭", "🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩", "💒", "🏛", "⛪", "🕌", "🛕", "🕍", "🕋", "⛩", "🛤", "🛣", "🗾", "🎑", "🏞", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆", "🏙", "🌃", "🌌", "🌉", "🌁" + ] + }, + { + name: "activities", + icon: "⚽", + emojis: [ + "⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛷", "⛸", "🥌", "🎿", "⛷", "🏂", "🪂", "🏋️‍♀️", "🏋️‍♂️", "🤼‍♀️", "🤼‍♂️", "🤸‍♀️", "🤸‍♂️", "⛹️‍♀️", "⛹️‍♂️", "🤺", "🤾‍♀️", "🤾‍♂️", "🏌️‍♀️", "🏌️‍♂️", "🏇", "🧘‍♀️", "🧘‍♂️", "🏄‍♀️", "🏄‍♂️", "🏊‍♀️", "🏊‍♂️", "🤽‍♀️", "🤽‍♂️", "🚣‍♀️", "🚣‍♂️", "🧗‍♀️", "🧗‍♂️", "🚵‍♀️", "🚵‍♂️", "🚴‍♀️", "🚴‍♂️", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖", "🏵", "🎗", "🎫", "🎟", "🎪", "🤹", "🤹‍♀️", "🤹‍♂️", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧", "🎼", "🎹", "🥁", "🎷", "🎺", "🎸", "🪕", "🎻", "🎲", "♠️", "♥️", "♦️", "♣️", "♟", "🃏", "🀄", "🎴", "🎯", "🎳", "🎮", "🎰", "🧩" + ] + }, + { + name: "objects", + icon: "📱", + emojis: [ + "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌", "💻", "🖥", "🖨", "⌨️", "🖱", "🖲", "💽", "💾", "💿", "📀", "🧮", "🎥", "📽", "📸", "📹", "📷", "🔍", "🔎", "🕯", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰", "🗞", "📑", "🔖", "🏷", "💰", "💴", "💵", "💶", "💷", "💸", "💳", "🧾", "💹", "💱", "💲", "✉️", "📧", "📨", "📩", "📤", "📥", "📦", "📫", "📪", "📬", "📭", "📮", "🗳", "✏️", "✒️", "🖋", "🖊", "🖌", "🖍", "📝", "💼", "📁", "📂", "🗂", "📅", "📆", "🗒", "🗓", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇", "📏", "📐", "✂️", "🗃", "🗄", "🗑", "🔒", "🔓", "🔏", "🔐", "🔑", "🗝", "🔨", "⛏", "⚒", "🛠", "🗡", "⚔️", "🔫", "🪃", "🏹", "🛡", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜", "⚖️", "🦯", "🔗", "⛓", "🧰", "🧲", "⚗️", "🧪", "🧫", "🧬", "🔬", "🔭", "📡", "💉", "💊", "🩹", "🩺", "🚪", "🛏", "🛋", "🚽", "🚿", "🛁", "🛀", "🧴", "🧷", "🧹", "🧺", "🧻", "🚰", "🚰", "🪒", "🧽", "🧯", "🛒" + ] + }, + { + name: "symbols", + icon: "❤️", + emojis: [ + "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "☮️", "✝️", "☪️", "🕉", "☸️", "✡️", "🔯", "🕎", "☯️", "☦️", "🛐", "⛎", "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", "🆔", "⚛️", "🉑", "☢️", "☣️", "📴", "📳", "🈶", "🈚", "🈸", "🈺", "🈷️", "✴️", "🆚", "💮", "🉐", "㊙️", "㊗️", "🈴", "🈵", "🈹", "🈲", "🅰️", "🅱️", "🆎", "🅾️", "🆘", "❌", "⭕", "🛑", "⛔", "📛", "🚫", "💯", "💢", "♨️", "🚷", "🚯", "🚳", "🚱", "🔞", "📵", "🚭", "❗", "❕", "❓", "❔", "‼️", "⁉️", "🔅", "🔆", "〽️", "⚠️", "🚸", "🔱", "⚜️", "🔰", "♻️", "✅", "🈯", "💹", "❇️", "✳️", "❎", "🌐", "💠", "Ⓜ️", "🌀", "💤", "🏧", "🚾", "♿", "🅿️", "🛗", "🈳", "🈂️", "🛂", "🛃", "🛄", "🛅", "🚹", "🚺", "🚼", "⚧", "🚻", "🚮", "🎦", "📶", "🈁", "🔣", "ℹ️", "🔤", "🔡", "🔠", "🆖", "🆗", "🆙", "🆒", "🆕", "🆓", "0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟" + ] + }, + { + name: "flags", + icon: "🏳️", + emojis: [ + "🏳️", "🏴", "🏁", "🚩", "🏳️‍🌈", "🏳️‍⚧️", "🏴‍☠️", "🇦🇨", "🇦🇩", "🇦🇪", "🇦🇫", "🇦🇬", "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼", "🇧🇾", "🇧🇿", "🇨🇦", "🇨🇨", "🇨🇩", "🇨🇫", "🇨🇬", "🇨🇭", "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", "🇨🇿", "🇩🇪", "🇩🇬", "🇩🇯", "🇩🇰", "🇩🇲", "🇩🇴", "🇩🇿", "🇪🇦", "🇪🇨", "🇪🇪", "🇪🇬", "🇪🇭", "🇪🇷", "🇪🇸", "🇪🇹", "🇪🇺", "🇫🇮", "🇫🇯", "🇫🇰", "🇫🇲", "🇫🇴", "🇫🇷", "🇬🇦", "🇬🇧", "🇬🇩", "🇬🇪", "🇬🇫", "🇬🇬", "🇬🇭", "🇬🇮", "🇬🇱", "🇬🇲", "🇬🇳", "🇬🇵", "🇬🇶", "🇬🇷", "🇬🇸", "🇬🇹", "🇬🇺", "🇬🇼", "🇬🇾", "🇭🇰", "🇭🇲", "🇭🇳", "🇭🇷", "🇭🇹", "🇭🇺", "🇮🇨", "🇮🇩", "🇮🇪", "🇮🇱", "🇮🇲", "🇮🇳", "🇮🇴", "🇮🇶", "🇮🇷", "🇮🇸", "🇮🇹", "🇯🇪", "🇯🇲", "🇯🇴", "🇯🇵", "🇰🇪", "🇰🇬", "🇰🇭", "🇰🇮", "🇰🇲", "🇰🇳", "🇰🇵", "🇰🇷", "🇰🇼", "🇰🇾", "🇰🇿", "🇱🇦", "🇱🇧", "🇱🇨", "🇱🇮", "🇱🇰", "🇱🇷", "🇱🇸", "🇱🇹", "🇱🇺", "🇱🇻", "🇱🇾", "🇲🇦", "🇲🇨", "🇲🇩", "🇲🇪", "🇲🇫", "🇲🇬", "🇲🇭", "🇲🇰", "🇲🇱", "🇲🇲", "🇲🇳", "🇲🇴", "🇲🇵", "🇲🇶", "🇲🇷", "🇲🇸", "🇲🇹", "🇲🇺", "🇲🇻", "🇲🇼", "🇲🇽", "🇲🇾", "🇲🇿", "🇳🇦", "🇳🇨", "🇳🇪", "🇳🇫", "🇳🇬", "🇳🇮", "🇳🇱", "🇳🇴", "🇳🇵", "🇳🇷", "🇳🇺", "🇳🇿", "🇴🇲", "🇵🇦", "🇵🇪", "🇵🇫", "🇵🇬", "🇵🇭", "🇵🇰", "🇵🇱", "🇵🇲", "🇵🇳", "🇵🇷", "🇵🇸", "🇵🇹", "🇵🇼", "🇵🇾", "🇶🇦", "🇷🇪", "🇷🇴", "🇷🇸", "🇷🇺", "🇷🇼", "🇸🇦", "🇸🇧", "🇸🇨", "🇸🇩", "🇸🇪", "🇸🇬", "🇸🇭", "🇸🇮", "🇸🇯", "🇸🇰", "🇸🇱", "🇸🇲", "🇸🇳", "🇸🇴", "🇸🇷", "🇸🇸", "🇸🇹", "🇸🇻", "🇸🇽", "🇸🇾", "🇸🇿", "🇹🇦", "🇹🇨", "🇹🇩", "🇹🇫", "🇹🇬", "🇹🇭", "🇹🇯", "🇹🇰", "🇹🇱", "🇹🇲", "🇹🇳", "🇹🇴", "🇹🇷", "🇹🇹", "🇹🇻", "🇹🇼", "🇹🇿", "🇺🇦", "🇺🇬", "🇺🇲", "🇺🇸", "🇺🇾", "🇺🇿", "🇻🇦", "🇻🇨", "🇻🇪", "🇻🇬", "🇻🇮", "🇻🇳", "🇻🇺", "🇼🇫", "🇼🇸", "🇾🇪", "🇾🇹", "🇿🇦", "🇿🇲", "🇿🇼" + ] + } +]; + +export const RECENT_EMOJIS_KEY = "recentEmojis"; + +export function getRecentEmojis(): string[] { + try { + const stored = localStorage.getItem(RECENT_EMOJIS_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +export function addRecentEmoji(emoji: string): void { + try { + let recentEmojis = getRecentEmojis(); + recentEmojis = recentEmojis.filter(e => e !== emoji); + recentEmojis.unshift(emoji); + recentEmojis = recentEmojis.slice(0, 50); + localStorage.setItem(RECENT_EMOJIS_KEY, JSON.stringify(recentEmojis)); + } catch { + // Ignore localStorage errors + } +} + diff --git a/frontend/src/pages/app/ui/components/profile/ProfileDialog.tsx b/frontend/src/pages/app/ui/components/profile/ProfileDialog.tsx index 1479460..1bb65ce 100644 --- a/frontend/src/pages/app/ui/components/profile/ProfileDialog.tsx +++ b/frontend/src/pages/app/ui/components/profile/ProfileDialog.tsx @@ -3,7 +3,7 @@ import defaultAvatar from "../../../resources/images/default-avatar.png"; import type { TextField } from "mdui/components/text-field"; import type { DialogProps } from "../../../core/types"; import { MaterialDialog } from "../core/Dialog"; -import { useProfile } from "../../hooks/useProfile"; +import useProfile from "../../hooks/useProfile"; import { ImageCropper } from "./ImageCropper"; import { MaterialTextField } from "../core/TextField"; diff --git a/frontend/src/pages/app/ui/hooks/useChat.ts b/frontend/src/pages/app/ui/hooks/useChat.ts deleted file mode 100644 index 58996c5..0000000 --- a/frontend/src/pages/app/ui/hooks/useChat.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useCallback, useRef } from "react"; -import { useAppState } from "../state"; -import { request } from "../../core/websocket"; -import { API_BASE_URL } from "../../core/config"; -import type { Message } from "../../core/types"; -import { getAuthHeaders } from "../../auth/api"; - -export function useChat() { - const { - chat, - addMessage, - updateMessage, - removeMessage, - clearMessages, - setCurrentChat, - setActiveTab, - setDmUsers, - setActiveDm, - setIsChatSwitching, - user - } = useAppState(); - - const messagesLoadedRef = useRef(false); - - // Load messages for the current chat - const loadMessages = useCallback(async () => { - if (!user.authToken || messagesLoadedRef.current) return; - - try { - const response = await fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders(user.authToken) - }); - - if (response.ok) { - const data = await response.json(); - if (data.messages && data.messages.length > 0) { - // Clear existing messages and add new ones - clearMessages(); - data.messages.forEach((msg: Message) => { - addMessage(msg); - }); - } - } - messagesLoadedRef.current = true; - } catch (error) { - console.error("Error loading messages:", error); - } - }, [user.authToken, addMessage, clearMessages]); - - // Send a message - const sendMessage = useCallback(async (content: string) => { - if (!user.authToken || !content.trim()) return; - - try { - const response = await request({ - data: { content: content.trim() }, - credentials: { - scheme: "Bearer", - credentials: user.authToken - }, - type: "sendMessage" - }); - - if (response.error) { - console.error("Error sending message:", response.error); - } - } catch (error) { - console.error("Error sending message:", error); - } - }, [user.authToken]); - - // WebSocket messages are now handled by the active panel - // No need for duplicate handling here - - // Load messages only once when component mounts and user is authenticated - useEffect(() => { - if (user.authToken && !messagesLoadedRef.current) { - loadMessages(); - } - }, [user.authToken, loadMessages]); - - // Reset messages loaded flag and clear messages when chat changes - useEffect(() => { - console.log("🔄 [DEBUG] useChat useEffect triggered for chat change:", chat.currentChat); - console.log("📝 [DEBUG] useChat: Resetting messages loaded flag and clearing messages"); - messagesLoadedRef.current = false; - clearMessages(); // Clear messages when switching chats - console.log("📥 [DEBUG] useChat: Loading messages for new chat"); - loadMessages(); - }, [chat.currentChat, clearMessages]); - - return { - messages: chat.messages, - currentChat: chat.currentChat, - activeTab: chat.activeTab, - dmUsers: chat.dmUsers, - activeDm: chat.activeDm, - isChatSwitching: chat.isChatSwitching, - setIsChatSwitching, - sendMessage, - updateMessage, - removeMessage, - clearMessages, - setCurrentChat, - setActiveTab, - setDmUsers, - setActiveDm - }; -} diff --git a/frontend/src/pages/app/ui/hooks/useDM.ts b/frontend/src/pages/app/ui/hooks/useDM.ts index 8b7cc8b..67bd2fe 100644 --- a/frontend/src/pages/app/ui/hooks/useDM.ts +++ b/frontend/src/pages/app/ui/hooks/useDM.ts @@ -10,7 +10,7 @@ import { import type { User, Message, DmEncryptedJSON } from "../../core/types"; import { websocket } from "../../core/websocket"; -interface DMUser extends User { +export interface DMUser extends User { lastMessage?: string; unreadCount: number; publicKey?: string | null; diff --git a/frontend/src/pages/app/ui/hooks/useProfile.ts b/frontend/src/pages/app/ui/hooks/useProfile.ts index aafb970..e256f78 100644 --- a/frontend/src/pages/app/ui/hooks/useProfile.ts +++ b/frontend/src/pages/app/ui/hooks/useProfile.ts @@ -3,7 +3,7 @@ import { useAppState } from "../state"; import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../api/profileApi"; import { showSuccess, showError } from "../../utils/notification"; -export function useProfile() { +export default function useProfile() { const { user } = useAppState(); const [profileData, setProfileData] = useState(null); const [isLoading, setIsLoading] = useState(false); diff --git a/frontend/src/pages/app/ui/panels/DMPanel.ts b/frontend/src/pages/app/ui/panels/DMPanel.ts index a100de7..5897708 100644 --- a/frontend/src/pages/app/ui/panels/DMPanel.ts +++ b/frontend/src/pages/app/ui/panels/DMPanel.ts @@ -33,15 +33,19 @@ export class DMPanel extends MessagePanel { } async activate(): Promise { - if (this.dmData && !this.messagesLoaded) { - await this.loadMessages(); - } + // Don't load messages immediately during activation to prevent animation freeze + // Messages will be loaded after the animation completes } deactivate(): void { // DM doesn't need special cleanup } + clearMessages(): void { + super.clearMessages(); + this.messagesLoaded = false; + } + private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { const plaintext = await decryptDm(env, this.dmData!.publicKey); const isAuthor = env.senderId !== this.dmData!.userId; @@ -164,7 +168,7 @@ export class DMPanel extends MessagePanel { } // Handle incoming WebSocket DM messages - handleWebSocketMessage = async (response: DMWebSocketMessage): Promise => { + async handleWebSocketMessage(response: DMWebSocketMessage): Promise { if (response.type === "dmNew" && this.dmData) { const envelope = response.data; diff --git a/frontend/src/pages/app/ui/panels/MessagePanel.ts b/frontend/src/pages/app/ui/panels/MessagePanel.ts index b3e4aa5..c0c69bb 100644 --- a/frontend/src/pages/app/ui/panels/MessagePanel.ts +++ b/frontend/src/pages/app/ui/panels/MessagePanel.ts @@ -1,4 +1,4 @@ -import type { Message } from "../../core/types"; +import type { Message, WebSocketMessage } from "../../core/types"; import type { UserState } from "../state"; export interface MessagePanelState { @@ -46,9 +46,7 @@ export abstract class MessagePanel { abstract loadMessages(): Promise; protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise; abstract isDm(): boolean; - - // Optional WebSocket message handler (can be overridden by subclasses) - handleWebSocketMessage?: (response: any) => void; + abstract handleWebSocketMessage(response: WebSocketMessage): Promise; // Common methods protected updateState(updates: Partial): void { diff --git a/frontend/src/pages/app/ui/panels/PublicChatPanel.ts b/frontend/src/pages/app/ui/panels/PublicChatPanel.ts index 2170d20..90c7d45 100644 --- a/frontend/src/pages/app/ui/panels/PublicChatPanel.ts +++ b/frontend/src/pages/app/ui/panels/PublicChatPanel.ts @@ -24,15 +24,19 @@ export class PublicChatPanel extends MessagePanel { } async activate(): Promise { - if (!this.messagesLoaded) { - await this.loadMessages(); - } + // Don't load messages immediately during activation to prevent animation freeze + // Messages will be loaded after the animation completes } deactivate(): void { // Public chat doesn't need special cleanup } + clearMessages(): void { + super.clearMessages(); + this.messagesLoaded = false; + } + async loadMessages(): Promise { if (!this.currentUser.authToken || this.messagesLoaded) return; @@ -100,7 +104,7 @@ export class PublicChatPanel extends MessagePanel { } // Handle incoming WebSocket messages - handleWebSocketMessage = (response: ChatWebSocketMessage): void => { + async handleWebSocketMessage(response: ChatWebSocketMessage): Promise { switch (response.type) { case 'messageEdited': if (response.data) { diff --git a/frontend/src/pages/app/ui/state.ts b/frontend/src/pages/app/ui/state.ts index 058e626..14899e0 100644 --- a/frontend/src/pages/app/ui/state.ts +++ b/frontend/src/pages/app/ui/state.ts @@ -24,10 +24,12 @@ interface ChatState { activeTab: ChatTabs; dmUsers: User[]; activeDm: ActiveDM | null; - isChatSwitching: boolean; + isSwitching: boolean; + setIsSwitching: (value: boolean) => void; activePanel: MessagePanel | null; publicChatPanel: PublicChatPanel | null; dmPanel: DMPanel | null; + pendingPanel?: MessagePanel | null; } export interface UserState { @@ -46,11 +48,11 @@ interface AppState { setDmUsers: (users: User[]) => void; setActiveDm: (dm: ChatState["activeDm"]) => void; clearMessages: () => void; - setIsChatSwitching: (value: boolean) => void; setActivePanel: (panel: MessagePanel | null) => void; + setPendingPanel: (panel: MessagePanel | null) => void; + applyPendingPanel: () => void; switchToPublicChat: (chatName: string) => Promise; switchToDM: (dmData: DMPanelData) => Promise; - switchToTab: (tab: ChatTabs) => Promise; // User state user: UserState; @@ -67,19 +69,17 @@ export const useAppState = create((set, get) => ({ activeTab: "chats", dmUsers: [], activeDm: null, - isChatSwitching: false, - activePanel: null, - publicChatPanel: null, - dmPanel: null - }, - setIsChatSwitching: (value: boolean) => { - console.log("🎬 [DEBUG] setIsChatSwitching called with:", value); - set((state) => ({ + isSwitching: false, + setIsSwitching: (value: boolean) => set((state) => ({ chat: { ...state.chat, - isChatSwitching: value + isSwitching: value } - })); + })), + activePanel: null, + publicChatPanel: null, + dmPanel: null, + pendingPanel: null }, addMessage: (message: Message) => set((state) => { // Check if message already exists to prevent duplicates @@ -258,104 +258,95 @@ export const useAppState = create((set, get) => ({ activePanel: panel } })), + // Stash a panel to be applied after switch-out animation ends + setPendingPanel: (panel: MessagePanel | null) => set((state) => ({ + chat: { + ...state.chat, + pendingPanel: panel + } + })), + // Apply pending panel atomically and update related fields + applyPendingPanel: () => set((state) => ({ + chat: { + ...state.chat, + activePanel: state.chat.pendingPanel || state.chat.activePanel, + // when switching to public chat, keep reference if type matches + publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel) + ? (state.chat.pendingPanel as PublicChatPanel) + : state.chat.publicChatPanel, + dmPanel: (state.chat.pendingPanel instanceof DMPanel) + ? (state.chat.pendingPanel as DMPanel) + : state.chat.dmPanel, + // update currentChat from panel title if available + currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat, + pendingPanel: null + } + })), switchToPublicChat: async (chatName: string) => { - console.log("🔄 [DEBUG] switchToPublicChat called with:", chatName); - const state = get(); - const { user, chat } = state; + const { user, chat } = get(); - if (!user.authToken) { - console.log("❌ [DEBUG] No auth token, returning early"); - return; - } - - console.log("🎬 [DEBUG] Starting chat switching animation for:", chatName); - console.log("🎬 [DEBUG] Current isChatSwitching state:", chat.isChatSwitching); + if (!user.authToken) return; // Start chat switching animation - state.setIsChatSwitching(true); + chat.setIsSwitching(true); // Create or get public chat panel let publicChatPanel = chat.publicChatPanel; if (!publicChatPanel) { - console.log("🆕 [DEBUG] Creating new PublicChatPanel for:", chatName); publicChatPanel = new PublicChatPanel(chatName, user); } else { - console.log("♻️ [DEBUG] Reusing existing PublicChatPanel, setting chat name to:", chatName); publicChatPanel.setChatName(chatName); publicChatPanel.setAuthToken(user.authToken); + // Reset messages for the new chat + publicChatPanel.clearMessages(); } - console.log("⏳ [DEBUG] Waiting 250ms for animation..."); - // Wait for animation - await new Promise(resolve => setTimeout(resolve, 250)); - - console.log("🚀 [DEBUG] Activating panel..."); // Activate panel await publicChatPanel.activate(); - console.log("📝 [DEBUG] Updating state with new panel and chat name"); - // Update state + // Defer panel swap until animation switch-out completes set((state) => ({ chat: { ...state.chat, - activePanel: publicChatPanel, - publicChatPanel: publicChatPanel, - currentChat: chatName, + pendingPanel: publicChatPanel, activeTab: "chats" } })); - console.log("✅ [DEBUG] Ending chat switching animation"); - // End animation - state.setIsChatSwitching(false); + // Let MessagePanelRenderer handle the animation timing completely + // It will set isChatSwitching to false when the fadeInDown animation completes }, switchToDM: async (dmData: DMPanelData) => { - console.log("🔄 [DEBUG] switchToDM called with:", dmData); - const state = get(); - const { user, chat } = state; + const { user, chat } = get(); - if (!user.authToken) { - console.log("❌ [DEBUG] No auth token, returning early"); - return; - } - - console.log("🎬 [DEBUG] Starting DM switching animation for user:", dmData.username); - console.log("🎬 [DEBUG] Current isChatSwitching state:", chat.isChatSwitching); + if (!user.authToken) return; // Start chat switching animation - state.setIsChatSwitching(true); + chat.setIsSwitching(true); // Create or get DM panel let dmPanel = chat.dmPanel; if (!dmPanel) { - console.log("🆕 [DEBUG] Creating new DMPanel for user:", dmData.username); dmPanel = new DMPanel(user); } else { - console.log("♻️ [DEBUG] Reusing existing DMPanel, updating auth token"); dmPanel.setAuthToken(user.authToken); + // Reset messages for the new DM + dmPanel.clearMessages(); } // Set DM data - console.log("📝 [DEBUG] Setting DM data for user:", dmData.username); dmPanel.setDMData(dmData); - console.log("⏳ [DEBUG] Waiting 250ms for animation..."); - // Wait for animation - await new Promise(resolve => setTimeout(resolve, 250)); - - console.log("🚀 [DEBUG] Activating DM panel..."); // Activate panel await dmPanel.activate(); - console.log("📝 [DEBUG] Updating state with new DM panel"); - // Update state + // Defer panel swap until animation switch-out completes set((state) => ({ chat: { ...state.chat, - activePanel: dmPanel, - dmPanel: dmPanel, + pendingPanel: dmPanel, activeDm: { userId: dmData.userId, username: dmData.username, @@ -365,24 +356,7 @@ export const useAppState = create((set, get) => ({ } })); - console.log("✅ [DEBUG] Ending DM switching animation"); - // End animation - state.setIsChatSwitching(false); - }, - - switchToTab: async (tab: ChatTabs) => { - console.log("🔄 [DEBUG] switchToTab called with:", tab); - const state = get(); - console.log("📝 [DEBUG] Setting active tab to:", tab); - state.setActiveTab(tab); - - if (tab === "chats") { - console.log("💬 [DEBUG] Switching to chats tab, calling switchToPublicChat"); - await state.switchToPublicChat("Общий чат"); - } else if (tab === "dms") { - console.log("💬 [DEBUG] Switching to DMs tab, clearing active panel"); - // DM tab - no specific panel until user is selected - state.setActivePanel(null); - } + // Let MessagePanelRenderer handle the animation timing completely + // It will set isChatSwitching to false when the fadeInDown animation completes } })); \ No newline at end of file