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/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/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) => ( { + 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 db0b443..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,10 @@ 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); @@ -25,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); @@ -56,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(); } @@ -88,60 +85,62 @@ 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 (
@@ -149,7 +148,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen ref={messagePanelRef} className="chat-main" id="chat-inner" - onDragEnter={(e) => { + onDragEnter={panel ? (e) => { if (!e.dataTransfer) return; e.preventDefault(); e.stopPropagation(); @@ -157,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(); @@ -180,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; }} - messagePanelRef={messagePanelRef} - /> + )} + + {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/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