diff --git a/.cursor/rules/browser.mdc b/.cursor/rules/browser.mdc new file mode 100644 index 0000000..73499dc --- /dev/null +++ b/.cursor/rules/browser.mdc @@ -0,0 +1,19 @@ +--- +alwaysApply: true +--- + +When using the browser, use this information to work better: + +## Login credentials + +Username: test +Password: 11111 + +## Server URL + +http://localhost:8301 + +## Rules +- Do NOT start the dev server yourself, it's started automatically. + If the URL doesn't work, stop and ask me to turn on the dev server. +- Don't wait, you are slow enough to keep up with the browser. \ No newline at end of file diff --git a/frontend/src/core/components/animations/AnimatedHeight.tsx b/frontend/src/core/components/animations/AnimatedHeight.tsx deleted file mode 100644 index 9b61a31..0000000 --- a/frontend/src/core/components/animations/AnimatedHeight.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect, useState, useRef } from "react"; -import type { AnimatedPropertyProps } from "./types"; - -export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) { - const [height, setHeight] = useState("0px"); - const [shouldRender, setShouldRender] = useState(!!visible); - const [isAnimating, setIsAnimating] = useState(false); - const measureRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - if (visible) { - setShouldRender(true); - setIsAnimating(true); - // Wait for content to render, then measure - setTimeout(() => { - if (measureRef.current) { - const contentHeight = measureRef.current.scrollHeight; - setHeight(`${contentHeight}px`); - } - // Animation complete - setTimeout(() => { - setHeight("auto"); - setIsAnimating(false); - }, duration * 1000); - }, 0); - } else if (shouldRender) { - setIsAnimating(true); - if (measureRef.current) { - const contentHeight = measureRef.current.scrollHeight; - setHeight(`${contentHeight}px`); - // Force a reflow before animating to 0 - requestAnimationFrame(() => { - // Read layout to ensure the previous height assignment is flushed - if (containerRef.current) { - containerRef.current.offsetHeight; - } - // Use a second frame to ensure the measured pixel height is applied before collapsing - requestAnimationFrame(() => { - setHeight("0px"); - }); - }); - } - // Hide content after animation completes - setTimeout(() => { - setShouldRender(false); - setIsAnimating(false); - if (onFinish) { - onFinish(); - } - }, duration * 1000); - } - }, [visible, shouldRender]); - - return (visible || shouldRender || isAnimating) && ( -
-
- {shouldRender && children} -
-
- ); -} \ No newline at end of file diff --git a/frontend/src/core/components/animations/AnimatedOpacity.tsx b/frontend/src/core/components/animations/AnimatedOpacity.tsx deleted file mode 100644 index 2ce6ad4..0000000 --- a/frontend/src/core/components/animations/AnimatedOpacity.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useState } from "react"; -import type { AnimatedPropertyProps } from "./types"; - -export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) { - const [opacity, setOpacity] = useState(visible ? 1 : 0); - const [shouldRender, setShouldRender] = useState(visible); - - useEffect(() => { - if (visible) { - setShouldRender(true); - setOpacity(0); - - // Wait for content to render, then animate in - const id = setTimeout(() => { - setOpacity(1); - }, 10); - return () => clearTimeout(id); - } else { - setOpacity(0); - - const id = setTimeout(() => { - setShouldRender(false); - if (onFinish) { - onFinish(); - } - }, duration * 1000); - return () => clearTimeout(id); - } - }, [visible, duration, onFinish]); - - return shouldRender && ( -
{children}
- ); -} \ No newline at end of file diff --git a/frontend/src/core/components/animations/types.d.ts b/frontend/src/core/components/animations/types.d.ts deleted file mode 100644 index f431740..0000000 --- a/frontend/src/core/components/animations/types.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactNode } from "react"; - -export interface BaseAnimatedPropertyProps { - visible: any; - duration?: number; - onFinish?: () => void - children?: ReactNode; -} - -export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div"> \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx index c718d1d..eabd33a 100644 --- a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx +++ b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useRef } from "react"; +import { motion, AnimatePresence } from "motion/react"; import { MaterialDialog } from "@/core/components/Dialog"; import { RichTextArea } from "@/core/components/RichTextArea"; import type { Message } from "@/core/types"; import Quote from "@/core/components/Quote"; -import AnimatedHeight from "@/core/components/animations/AnimatedHeight"; import { useImmer } from "use-immer"; import { EmojiMenu } from "./EmojiMenu"; @@ -145,58 +145,82 @@ export function ChatInputWrapper( return (
- - {editingMessage && ( -
- - - {editingMessage!.username} - {editingMessage!.content} - - -
- )} -
- - {replyTo && ( -
- - - {replyTo!.username} - {replyTo!.content} - - -
- )} -
- setSelectedFiles([])}> - {selectedFiles.length > 0 && ( -
- -
- {selectedFiles.map((file, i) => ( - { - if (selectedFiles.length == 1) { - setAttachmentsVisible(false); - } else { - setSelectedFiles(draft => { draft.splice(i) }) - } - }} - > - - {file.name} - - ))} + + {editVisible && editingMessage && ( + +
+ + + {editingMessage!.username} + {editingMessage!.content} + +
- setAttachmentsVisible(false)}> -
+ )} - + + + {replyToVisible && replyTo && ( + +
+ + + {replyTo!.username} + {replyTo!.content} + + +
+
+ )} +
+ setSelectedFiles([])}> + {attachmentsVisible && selectedFiles.length > 0 && ( + +
+ +
+ {selectedFiles.map((file, i) => ( + { + if (selectedFiles.length == 1) { + setAttachmentsVisible(false); + } else { + setSelectedFiles(draft => { draft.splice(i) }) + } + }} + > + + {file.name} + + ))} +
+ setAttachmentsVisible(false)}> +
+
+ )} +
add
- ) : ( -
- -
- )} + ) : ( +
+ +
+ )}
{/* Context Menu */} diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index eeed80d..5714ab6 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; +import { motion, AnimatePresence } from "motion/react"; import { useAppState } from "@/pages/chat/state"; import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { ChatMessages } from "./ChatMessages"; @@ -7,7 +8,6 @@ import { ProfileDialog } from "@/pages/chat/ui/ProfileDialog"; import { setGlobalMessageHandler } from "@/core/websocket"; import type { Message, WebSocketMessage } from "@/core/types"; import defaultAvatar from "@/images/default-avatar.png"; -import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity"; import { DMPanel } from "./panels/DMPanel"; import useCall from "@/pages/chat/hooks/useCall"; import { TypingIndicator } from "./TypingIndicator"; @@ -48,8 +48,6 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { const { applyPendingPanel, chat, setProfileDialog } = useAppState(); const messagePanelRef = useRef(null); const [panelState, setPanelState] = useState(null); - const [switchIn, setSwitchIn] = useState(false); - const [switchOut, setSwitchOut] = useState(false); const messagesEndRef = useRef(null); const previousMessageCountRef = useRef(0); const [replyTo, setReplyTo] = useState(null); @@ -118,51 +116,32 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { }; }, [panel]); - // Handle chat switching animation with event listeners + // Handle chat switching animation useEffect(() => { - if (chat.isSwitching) { - setSwitchOut(true); - - // 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); - // 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); - }; + if (chat.isSwitching && chat.pendingPanel) { + // Apply pending panel when animation starts + applyPendingPanel(); + // End switching state after a brief delay to allow animation + setTimeout(() => { + chat.setIsSwitching(false); + }, 200); } - }, [chat.isSwitching]); + }, [chat.isSwitching, chat.pendingPanel, applyPendingPanel]); // Load messages when panel changes and animation is not running useEffect(() => { - if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return; + if (!chat.activePanel || chat.isSwitching) return; const panelState = chat.activePanel.getState(); if (panelState.messages.length === 0 && !panelState.isLoading) { chat.activePanel.loadMessages(); } - }, [chat.activePanel, chat.isSwitching, switchOut, switchIn]); + }, [chat.activePanel, chat.isSwitching]); // Scroll to bottom only when new messages are added useEffect(() => { - if (!panelState || chat.isSwitching || switchOut || switchIn) return; + if (!panelState || chat.isSwitching) return; const currentMessageCount = panelState.messages.length; const previousMessageCount = previousMessageCountRef.current; @@ -186,7 +165,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { // Update the previous message count previousMessageCountRef.current = currentMessageCount; - }, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]); + }, [panelState?.messages, panelState?.isLoading, chat.isSwitching]); function handleCallClick() { if (panel && panelState && panel.isDm()) { @@ -213,12 +192,20 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } } + const panelKey = chat.activePanel?.getState().title || "empty"; + return ( -
-
+ + { if (!e.dataTransfer) return; e.preventDefault(); @@ -324,18 +311,26 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { {panel && ( <> - e.preventDefault()} - onDrop={(e) => e.preventDefault()}> -
-
- - Отпустите файл(ы) для добавления -
-
-
+ + {isDragging && ( + e.preventDefault()} + onDrop={(e) => e.preventDefault()} + > +
+
+ + Отпустите файл(ы) для добавления +
+
+
+ )} +
)} -
+ + {/* Profile Dialog */}