diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 696ed8c..2f420a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,17 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; -import { ElectronTitleBar } from "./pages/chat/ui/components/Electron"; -import { useAppState } from "./pages/chat/ui/state"; +import { ElectronTitleBar } from "./Electron"; +import { useAppState } from "./pages/chat/state"; import { useEffect, useState } from "react"; import HomePage from "./pages/home/HomePage"; import LoginPage from "./pages/auth/LoginPage"; import RegisterPage from "./pages/auth/RegisterPage"; -import ChatPage from "./pages/chat/ChatPage"; +import ChatPage from "./pages/chat/ui/ChatPage"; import DownloadAppPage from "./pages/download-app/DownloadAppPage"; import NotFoundPage from "./pages/not-found/NotFoundPage"; import ProtectedRoute from "./pages/ProtectedRoute"; import { isElectron } from "./core/electron/electron"; import { MINIMUM_WIDTH } from "./core/config"; -import useWindowSize from "./pages/chat/ui/hooks/useWindowSize"; +import useWindowSize from "./core/hooks/useWindowSize"; export default function App() { const { restoreUserFromStorage } = useAppState(); diff --git a/frontend/src/pages/chat/ui/components/Electron.tsx b/frontend/src/Electron.tsx similarity index 69% rename from frontend/src/pages/chat/ui/components/Electron.tsx rename to frontend/src/Electron.tsx index 296d9d1..03c2522 100644 --- a/frontend/src/pages/chat/ui/components/Electron.tsx +++ b/frontend/src/Electron.tsx @@ -1,5 +1,5 @@ -import { PRODUCT_NAME } from "../../../../core/config"; -import { isElectron } from "../../../../core/electron/electron"; +import { PRODUCT_NAME } from "./core/config"; +import { isElectron } from "./core/electron/electron"; export function ElectronTitleBar() { return isElectron && ( diff --git a/frontend/src/pages/chat/ui/components/core/Dialog.tsx b/frontend/src/core/components/Dialog.tsx similarity index 93% rename from frontend/src/pages/chat/ui/components/core/Dialog.tsx rename to frontend/src/core/components/Dialog.tsx index 43d1923..35ef39b 100644 --- a/frontend/src/pages/chat/ui/components/core/Dialog.tsx +++ b/frontend/src/core/components/Dialog.tsx @@ -1,8 +1,8 @@ import type { Dialog as MduiDialog } from "mdui/components/dialog"; import { useEffect, type Ref } from "react" import { createPortal } from "react-dom"; -import { id } from "../../../../../utils/utils"; -import useCombinedRefs from "../../hooks/useCombinedRefs"; +import { id } from "../../utils/utils"; +import useCombinedRefs from "../hooks/useCombinedRefs"; export interface BaseDialogProps { onOpenChange: (value: boolean) => void; diff --git a/frontend/src/pages/chat/ui/components/core/Quote.tsx b/frontend/src/core/components/Quote.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/core/Quote.tsx rename to frontend/src/core/components/Quote.tsx diff --git a/frontend/src/pages/chat/ui/components/core/RichTextArea.tsx b/frontend/src/core/components/RichTextArea.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/core/RichTextArea.tsx rename to frontend/src/core/components/RichTextArea.tsx diff --git a/frontend/src/pages/chat/ui/components/core/TextField.tsx b/frontend/src/core/components/TextField.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/core/TextField.tsx rename to frontend/src/core/components/TextField.tsx diff --git a/frontend/src/pages/chat/ui/components/core/animations/AnimatedHeight.tsx b/frontend/src/core/components/animations/AnimatedHeight.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/core/animations/AnimatedHeight.tsx rename to frontend/src/core/components/animations/AnimatedHeight.tsx diff --git a/frontend/src/pages/chat/ui/components/core/animations/AnimatedOpacity.tsx b/frontend/src/core/components/animations/AnimatedOpacity.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/core/animations/AnimatedOpacity.tsx rename to frontend/src/core/components/animations/AnimatedOpacity.tsx diff --git a/frontend/src/pages/chat/ui/components/core/animations/types.d.ts b/frontend/src/core/components/animations/types.d.ts similarity index 100% rename from frontend/src/pages/chat/ui/components/core/animations/types.d.ts rename to frontend/src/core/components/animations/types.d.ts diff --git a/frontend/src/pages/chat/ui/hooks/useCombinedRefs.ts b/frontend/src/core/hooks/useCombinedRefs.ts similarity index 100% rename from frontend/src/pages/chat/ui/hooks/useCombinedRefs.ts rename to frontend/src/core/hooks/useCombinedRefs.ts diff --git a/frontend/src/pages/chat/ui/hooks/useWindowSize.ts b/frontend/src/core/hooks/useWindowSize.ts similarity index 100% rename from frontend/src/pages/chat/ui/hooks/useWindowSize.ts rename to frontend/src/core/hooks/useWindowSize.ts diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx index 5036612..27fac5e 100644 --- a/frontend/src/pages/ProtectedRoute.tsx +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useAppState } from "./chat/ui/state"; +import { useAppState } from "./chat/state"; import { useNavigate } from "react-router-dom"; interface ProtectedRouteProps { diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index a293c19..78e7cd8 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -6,8 +6,8 @@ import { ensureKeysOnLogin } from "../../core/api/authApi"; import { API_BASE_URL } from "../../core/config"; import { useRef } from "react"; import type { TextField } from "mdui/components/text-field"; -import { useAppState } from "../chat/ui/state"; -import { MaterialTextField } from "../chat/ui/components/core/TextField"; +import { useAppState } from "../chat/state"; +import { MaterialTextField } from "../../core/components/TextField"; import { initialize, isSupported, startElectronReceiver, subscribe } from "../../core/push-notifications/push-notifications"; import { isElectron } from "../../core/electron/electron"; import { useNavigate } from "react-router-dom"; diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx index 3e06a4a..f484e51 100644 --- a/frontend/src/pages/auth/RegisterPage.tsx +++ b/frontend/src/pages/auth/RegisterPage.tsx @@ -5,8 +5,8 @@ import { useRef } from "react"; import { TextField } from "mdui/components/text-field"; import type { ErrorResponse, RegisterRequest, LoginResponse } from "../../core/types"; import { API_BASE_URL } from "../../core/config"; -import { useAppState } from "../chat/ui/state"; -import { MaterialTextField } from "../chat/ui/components/core/TextField"; +import { useAppState } from "../chat/state"; +import { MaterialTextField } from "../../core/components/TextField"; import { ensureKeysOnLogin } from "../../core/api/authApi"; import { useNavigate } from "react-router-dom"; import "./auth.scss"; diff --git a/frontend/src/pages/chat/ui/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts similarity index 98% rename from frontend/src/pages/chat/ui/hooks/useDM.ts rename to frontend/src/pages/chat/hooks/useDM.ts index 713073b..f3db530 100644 --- a/frontend/src/pages/chat/ui/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -6,9 +6,9 @@ import { fetchDMHistory, decryptDm, sendDMViaWebSocket -} from "../../../../core/api/dmApi"; -import type { User, Message, DmEncryptedJSON } from "../../../../core/types"; -import { websocket } from "../../../../core/websocket"; +} from "../../../core/api/dmApi"; +import type { User, Message, DmEncryptedJSON } from "../../../core/types"; +import { websocket } from "../../../core/websocket"; export interface DMUser extends User { lastMessage?: string; diff --git a/frontend/src/pages/chat/ui/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts similarity index 96% rename from frontend/src/pages/chat/ui/hooks/useProfile.ts rename to frontend/src/pages/chat/hooks/useProfile.ts index 329f753..c4c2764 100644 --- a/frontend/src/pages/chat/ui/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from "react"; import { useAppState } from "../state"; -import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../../../core/api/profileApi"; -import { showSuccess, showError } from "../../../../utils/notification"; +import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../../core/api/profileApi"; +import { showSuccess, showError } from "../../../utils/notification"; export default function useProfile() { const { user } = useAppState(); diff --git a/frontend/src/pages/chat/ui/state.ts b/frontend/src/pages/chat/state.ts similarity index 95% rename from frontend/src/pages/chat/ui/state.ts rename to frontend/src/pages/chat/state.ts index ded50fe..a7eff88 100644 --- a/frontend/src/pages/chat/ui/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -1,14 +1,14 @@ import { create } from "zustand"; -import type { Message, User } from "../../../core/types"; -import { request } from "../../../core/websocket"; -import { MessagePanel } from "./panels/MessagePanel"; -import { PublicChatPanel } from "./panels/PublicChatPanel"; -import { DMPanel, type DMPanelData } from "./panels/DMPanel"; -import { getAuthHeaders } from "../../../core/api/authApi"; -import { restoreKeys } from "../../../core/api/authApi"; -import { API_BASE_URL } from "../../../core/config"; -import { initialize, subscribe, startElectronReceiver, isSupported } from "../../../core/push-notifications/push-notifications"; -import { isElectron } from "../../../core/electron/electron"; +import type { Message, User } from "../../core/types"; +import { request } from "../../core/websocket"; +import { MessagePanel } from "./ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel"; +import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel"; +import { getAuthHeaders } from "../../core/api/authApi"; +import { restoreKeys } from "../../core/api/authApi"; +import { API_BASE_URL } from "../../core/config"; +import { initialize, subscribe, startElectronReceiver, isSupported } from "../../core/push-notifications/push-notifications"; +import { isElectron } from "../../core/electron/electron"; export type ChatTabs = "chats" | "channels" | "contacts" | "dms" diff --git a/frontend/src/pages/chat/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx similarity index 61% rename from frontend/src/pages/chat/ChatPage.tsx rename to frontend/src/pages/chat/ui/ChatPage.tsx index 19cb5a2..b5024e9 100644 --- a/frontend/src/pages/chat/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -1,6 +1,6 @@ -import { LeftPanel } from "./ui/components/chat/LeftPanel"; -import { RightPanel } from "./ui/components/chat/RightPanel"; -import "./css/chat.scss"; +import { LeftPanel } from "./left/LeftPanel"; +import { RightPanel } from "./right/RightPanel"; +import "../css/chat.scss"; export default function ChatPage() { return ( diff --git a/frontend/src/pages/chat/ui/components/chat/MessageReactions.tsx b/frontend/src/pages/chat/ui/components/chat/MessageReactions.tsx deleted file mode 100644 index d811d1a..0000000 --- a/frontend/src/pages/chat/ui/components/chat/MessageReactions.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useAppState } from "../../state"; -import { useState, useEffect } from "react"; -import type { Reaction } from "../../../../../core/types"; - -interface MessageReactionsProps { - reactions?: Reaction[]; - onReactionClick: (emoji: string) => void; - messageId?: number; // Add messageId to ensure unique keys -} - -export function MessageReactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) { - const { user } = useAppState(); - const [visibleReactions, setVisibleReactions] = useState([]); - const [animatingReactions, setAnimatingReactions] = useState>(new Set()); - const [isVisible, setIsVisible] = useState(false); - - // Handle reactions with animation - useEffect(() => { - if (!reactions || reactions.length === 0) { - // If we have visible reactions, animate them out - if (visibleReactions.length > 0) { - visibleReactions.forEach(reaction => { - setAnimatingReactions(prev => new Set(prev).add(reaction.emoji)); - }); - // After animation completes, hide the component - setTimeout(() => { - setVisibleReactions([]); - setAnimatingReactions(new Set()); - setIsVisible(false); - }, 200); - } else { - // No visible reactions, hide immediately - setIsVisible(false); - } - return; - } - - // Show the component when we have reactions - setIsVisible(true); - - // Deduplicate reactions by emoji (safety measure) - const uniqueReactions = reactions.reduce((acc, reaction) => { - const existing = acc.find(r => r.emoji === reaction.emoji); - if (existing) { - // Keep the one with the higher count - if (reaction.count > existing.count) { - acc[acc.indexOf(existing)] = reaction; - } - } else { - acc.push(reaction); - } - return acc; - }, [] as Reaction[]); - - - // Animate out removed reactions - visibleReactions.forEach(reaction => { - if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) { - setAnimatingReactions(prev => new Set(prev).add(reaction.emoji)); - setTimeout(() => { - setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji)); - setAnimatingReactions(prev => { - const newSet = new Set(prev); - newSet.delete(reaction.emoji); - return newSet; - }); - }, 200); - } - }); - - // Update existing reactions and add new ones - setVisibleReactions(prev => { - const updated = [...prev]; - - // Update existing reactions - uniqueReactions.forEach(reaction => { - const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji); - if (existingIndex !== -1) { - updated[existingIndex] = reaction; - } else { - // Add new reaction only if it doesn't already exist - if (!updated.some(r => r.emoji === reaction.emoji)) { - updated.push(reaction); - } - } - }); - - return updated; - }); - }, [reactions]); - - // Don't render if not visible - if (!isVisible) { - return null; - } - - return ( -
- {visibleReactions.map((reaction, index) => { - const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id); - const isAnimating = animatingReactions.has(reaction.emoji); - - return ( - - ); - })} -
- ); -} diff --git a/frontend/src/pages/chat/ui/components/chat/ReplyMessageDialog.tsx b/frontend/src/pages/chat/ui/components/chat/ReplyMessageDialog.tsx deleted file mode 100644 index 1207f28..0000000 --- a/frontend/src/pages/chat/ui/components/chat/ReplyMessageDialog.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useEffect } from "react"; -import type { Message } from "../../../../../core/types"; -import { MaterialDialog } from "../core/Dialog"; -import { MaterialTextField } from "../core/TextField"; - -interface ReplyMessageDialogProps { - isOpen: boolean; - onOpenChange: (value: boolean) => void; - replyToMessage: Message | null; - onSendReply: (content: string, replyToId: number) => void; -} - -export function ReplyMessageDialog({ isOpen, onOpenChange, replyToMessage, onSendReply }: ReplyMessageDialogProps) { - const [replyContent, setReplyContent] = useState(""); - - useEffect(() => { - if (replyToMessage) { - setReplyContent(""); - } - }, [replyToMessage]); - - const handleSendReply = () => { - if (replyToMessage && replyContent.trim()) { - onSendReply(replyContent.trim(), replyToMessage.id); - onOpenChange(false); - } - }; - - const handleCancel = () => { - onOpenChange(false); - setReplyContent(""); - }; - - if (!replyToMessage) return null; - - return ( - -
-

Ответить на сообщение

-
-
- {replyToMessage.username} - {replyToMessage.content} -
-
- setReplyContent((e.target as HTMLInputElement).value)} - label="Reply" - variant="outlined" - placeholder="Type your reply..." - maxlength={1000} /> -
- Cancel - Send Reply -
-
-
- ); -} diff --git a/frontend/src/pages/chat/ui/components/chat/BottomAppBar.tsx b/frontend/src/pages/chat/ui/left/BottomAppBar.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/chat/BottomAppBar.tsx rename to frontend/src/pages/chat/ui/left/BottomAppBar.tsx diff --git a/frontend/src/pages/chat/ui/components/chat/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx similarity index 91% rename from frontend/src/pages/chat/ui/components/chat/ChatHeader.tsx rename to frontend/src/pages/chat/ui/left/ChatHeader.tsx index 0569871..b94297e 100644 --- a/frontend/src/pages/chat/ui/components/chat/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -1,8 +1,8 @@ -import { PRODUCT_NAME } from "../../../../../core/config"; +import { PRODUCT_NAME } from "../../../../core/config"; import useProfile from "../../hooks/useProfile"; import defaultAvatar from "../../../resources/images/default-avatar.png"; import { useState } from "react"; -import { ProfileDialog } from "../profile/ProfileDialog"; +import { ProfileDialog } from "./profile/ProfileDialog"; export function ChatHeader() { const { profileData } = useProfile(); diff --git a/frontend/src/pages/chat/ui/components/chat/ChatTabs.tsx b/frontend/src/pages/chat/ui/left/ChatTabs.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/chat/ChatTabs.tsx rename to frontend/src/pages/chat/ui/left/ChatTabs.tsx diff --git a/frontend/src/pages/chat/ui/components/chat/DMUsersList.tsx b/frontend/src/pages/chat/ui/left/DMUsersList.tsx similarity index 95% rename from frontend/src/pages/chat/ui/components/chat/DMUsersList.tsx rename to frontend/src/pages/chat/ui/left/DMUsersList.tsx index 89e820c..022532d 100644 --- a/frontend/src/pages/chat/ui/components/chat/DMUsersList.tsx +++ b/frontend/src/pages/chat/ui/left/DMUsersList.tsx @@ -1,8 +1,8 @@ import { useEffect } from "react"; import { useDM, type DMUser } from "../../hooks/useDM"; import { useAppState } from "../../state"; -import { fetchUserPublicKey } from "../../../../../core/api/dmApi"; -import defaultAvatar from "../../../../../images/default-avatar.png"; +import { fetchUserPublicKey } from "../../../../core/api/dmApi"; +import defaultAvatar from "../../../../images/default-avatar.png"; export function DMUsersList() { const { dmUsers, isLoadingUsers, loadUsers } = useDM(); diff --git a/frontend/src/pages/chat/ui/components/chat/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx similarity index 94% rename from frontend/src/pages/chat/ui/components/chat/LeftPanel.tsx rename to frontend/src/pages/chat/ui/left/LeftPanel.tsx index 9d15d35..a74a8aa 100644 --- a/frontend/src/pages/chat/ui/components/chat/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -1,9 +1,9 @@ -import { PRODUCT_NAME } from "../../../../../core/config"; +import { PRODUCT_NAME } from "../../../../core/config"; import { useAppState } from "../../state"; -import defaultAvatar from "../../../../../images/default-avatar.png"; +import defaultAvatar from "../../../../images/default-avatar.png"; import { useState, type FormEvent } from "react"; -import { ProfileDialog } from "../profile/ProfileDialog"; -import { SettingsDialog } from "../settings/SettingsDialog"; +import { ProfileDialog } from "./profile/ProfileDialog"; +import { SettingsDialog } from "./settings/SettingsDialog"; import { DMUsersList } from "./DMUsersList"; import type { Tabs } from "mdui"; import type { ChatTabs } from "../../state"; diff --git a/frontend/src/pages/chat/ui/components/profile/CropperDialog.tsx b/frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/profile/CropperDialog.tsx rename to frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx diff --git a/frontend/src/pages/chat/ui/components/profile/ImageCropper.tsx b/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/profile/ImageCropper.tsx rename to frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx diff --git a/frontend/src/pages/chat/ui/components/profile/ProfileDialog.tsx b/frontend/src/pages/chat/ui/left/profile/ProfileDialog.tsx similarity index 97% rename from frontend/src/pages/chat/ui/components/profile/ProfileDialog.tsx rename to frontend/src/pages/chat/ui/left/profile/ProfileDialog.tsx index 3d9f558..1ad53de 100644 --- a/frontend/src/pages/chat/ui/components/profile/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/left/profile/ProfileDialog.tsx @@ -2,10 +2,10 @@ import { useState, useEffect, useRef, type FormEvent } from "react"; import defaultAvatar from "../../../../../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 { MaterialDialog } from "../../../../../core/components/Dialog"; +import useProfile from "../../../hooks/useProfile"; import { ImageCropper } from "./ImageCropper"; -import { MaterialTextField } from "../core/TextField"; +import { MaterialTextField } from "../../../../../core/components/TextField"; export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) { const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile(); diff --git a/frontend/src/pages/chat/ui/components/settings/SettingsDialog.tsx b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx similarity index 99% rename from frontend/src/pages/chat/ui/components/settings/SettingsDialog.tsx rename to frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx index dddd301..806891e 100644 --- a/frontend/src/pages/chat/ui/components/settings/SettingsDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from "react"; import { PRODUCT_NAME, API_BASE_URL } from "../../../../../core/config"; import type { DialogProps } from "../../../../../core/types"; -import { MaterialDialog } from "../core/Dialog"; +import { MaterialDialog } from "../../../../../core/components/Dialog"; import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../../../core/push-notifications/push-notifications"; import { isElectron } from "../../../../../core/electron/electron"; -import { useAppState } from "../../state"; +import { useAppState } from "../../../state"; import type { Switch } from "mdui/components/switch"; import { getAuthHeaders } from "../../../../../core/api/authApi"; diff --git a/frontend/src/pages/chat/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx similarity index 96% rename from frontend/src/pages/chat/ui/components/chat/ChatInputWrapper.tsx rename to frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx index 7f2d42f..eeccf6e 100644 --- a/frontend/src/pages/chat/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx @@ -1,9 +1,9 @@ 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 { 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"; diff --git a/frontend/src/pages/chat/ui/components/chat/ChatMainHeader.tsx b/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/chat/ChatMainHeader.tsx rename to frontend/src/pages/chat/ui/right/ChatMainHeader.tsx diff --git a/frontend/src/pages/chat/ui/components/chat/ChatMessages.tsx b/frontend/src/pages/chat/ui/right/ChatMessages.tsx similarity index 94% rename from frontend/src/pages/chat/ui/components/chat/ChatMessages.tsx rename to frontend/src/pages/chat/ui/right/ChatMessages.tsx index a5cf808..3d8d8f7 100644 --- a/frontend/src/pages/chat/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMessages.tsx @@ -1,15 +1,15 @@ import { Message } from "./Message"; import { useAppState } from "../../state"; -import type { Message as MessageType } from "../../../../../core/types"; -import type { UserProfile } from "../../../../../core/types"; +import type { Message as MessageType } from "../../../../core/types"; +import type { UserProfile } from "../../../../core/types"; import { UserProfileDialog } from "./UserProfileDialog"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; -import { fetchUserProfile } from "../../../../../core/api/profileApi"; +import { fetchUserProfile } from "../../../../core/api/profileApi"; import { useEffect, useState, type ReactNode } from "react"; -import { delay } from "../../../../../utils/utils"; -import { MaterialDialog } from "../core/Dialog"; -import { request } from "../../../../../core/websocket"; -import type { AddReactionRequest, AddDmReactionRequest } from "../../../../../core/types"; +import { delay } from "../../../../utils/utils"; +import { MaterialDialog } from "../../../../core/components/Dialog"; +import { request } from "../../../../core/websocket"; +import type { AddReactionRequest, AddDmReactionRequest } from "../../../../core/types"; interface ChatMessagesProps { messages?: MessageType[]; diff --git a/frontend/src/pages/chat/ui/components/chat/EmojiMenu.tsx b/frontend/src/pages/chat/ui/right/EmojiMenu.tsx similarity index 99% rename from frontend/src/pages/chat/ui/components/chat/EmojiMenu.tsx rename to frontend/src/pages/chat/ui/right/EmojiMenu.tsx index a7c0b8d..196166c 100644 --- a/frontend/src/pages/chat/ui/components/chat/EmojiMenu.tsx +++ b/frontend/src/pages/chat/ui/right/EmojiMenu.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData"; -import type { Size2D } from "../../../../../core/types"; +import type { Size2D } from "../../../../core/types"; interface BaseEmojiMenuProps { isOpen: boolean; diff --git a/frontend/src/pages/chat/ui/components/chat/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx similarity index 79% rename from frontend/src/pages/chat/ui/components/chat/Message.tsx rename to frontend/src/pages/chat/ui/right/Message.tsx index 058f85a..a7e8cbc 100644 --- a/frontend/src/pages/chat/ui/components/chat/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -1,19 +1,133 @@ -import { formatTime, id } from "../../../../../utils/utils"; -import type { Attachment, Message as MessageType } from "../../../../../core/types"; -import defaultAvatar from "../../../../../images/default-avatar.png"; -import Quote from "../core/Quote"; +import { formatTime, id } from "../../../../utils/utils"; +import type { Attachment, Message as MessageType, Reaction } from "../../../../core/types"; +import defaultAvatar from "../../../../images/default-avatar.png"; +import Quote from "../../../../core/components/Quote"; import { parse } from "marked"; import DOMPurify from "dompurify"; import { useEffect, useState, useRef } from "react"; -import { getCurrentKeys } from "../../../../../core/api/authApi"; -import { ecdhSharedSecret, deriveWrappingKey } from "../../../../../utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmDecrypt } from "../../../../../utils/crypto/symmetric"; -import { getAuthHeaders } from "../../../../../core/api/authApi"; +import { getCurrentKeys } from "../../../../core/api/authApi"; +import { ecdhSharedSecret, deriveWrappingKey } from "../../../../utils/crypto/asymmetric"; +import { importAesGcmKey, aesGcmDecrypt } from "../../../../utils/crypto/symmetric"; +import { getAuthHeaders } from "../../../../core/api/authApi"; import { useAppState } from "../../state"; -import { ub64 } from "../../../../../utils/utils"; +import { ub64 } from "../../../../utils/utils"; import { useImmer } from "use-immer"; import { createPortal } from "react-dom"; -import { MessageReactions } from "./MessageReactions"; + +interface MessageReactionsProps { + reactions?: Reaction[]; + onReactionClick: (emoji: string) => void; + messageId?: number; // Add messageId to ensure unique keys +} + +function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) { + const { user } = useAppState(); + const [visibleReactions, setVisibleReactions] = useState([]); + const [animatingReactions, setAnimatingReactions] = useState>(new Set()); + const [isVisible, setIsVisible] = useState(false); + + // Handle reactions with animation + useEffect(() => { + if (!reactions || reactions.length === 0) { + // If we have visible reactions, animate them out + if (visibleReactions.length > 0) { + visibleReactions.forEach(reaction => { + setAnimatingReactions(prev => new Set(prev).add(reaction.emoji)); + }); + // After animation completes, hide the component + setTimeout(() => { + setVisibleReactions([]); + setAnimatingReactions(new Set()); + setIsVisible(false); + }, 200); + } else { + // No visible reactions, hide immediately + setIsVisible(false); + } + return; + } + + // Show the component when we have reactions + setIsVisible(true); + + // Deduplicate reactions by emoji (safety measure) + const uniqueReactions = reactions.reduce((acc, reaction) => { + const existing = acc.find(r => r.emoji === reaction.emoji); + if (existing) { + // Keep the one with the higher count + if (reaction.count > existing.count) { + acc[acc.indexOf(existing)] = reaction; + } + } else { + acc.push(reaction); + } + return acc; + }, [] as Reaction[]); + + + // Animate out removed reactions + visibleReactions.forEach(reaction => { + if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) { + setAnimatingReactions(prev => new Set(prev).add(reaction.emoji)); + setTimeout(() => { + setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji)); + setAnimatingReactions(prev => { + const newSet = new Set(prev); + newSet.delete(reaction.emoji); + return newSet; + }); + }, 200); + } + }); + + // Update existing reactions and add new ones + setVisibleReactions(prev => { + const updated = [...prev]; + + // Update existing reactions + uniqueReactions.forEach(reaction => { + const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji); + if (existingIndex !== -1) { + updated[existingIndex] = reaction; + } else { + // Add new reaction only if it doesn't already exist + if (!updated.some(r => r.emoji === reaction.emoji)) { + updated.push(reaction); + } + } + }); + + return updated; + }); + }, [reactions]); + + // Don't render if not visible + if (!isVisible) { + return null; + } + + return ( +
+ {visibleReactions.map((reaction, index) => { + const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id); + const isAnimating = animatingReactions.has(reaction.emoji); + + return ( + + ); + })} +
+ ); +} + interface MessageProps { message: MessageType; @@ -375,7 +489,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe )} - onReactionClick?.(message.id, emoji)} messageId={message.id} diff --git a/frontend/src/pages/chat/ui/components/chat/MessageContextMenu.tsx b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx similarity index 99% rename from frontend/src/pages/chat/ui/components/chat/MessageContextMenu.tsx rename to frontend/src/pages/chat/ui/right/MessageContextMenu.tsx index 6db8a5e..c13677e 100644 --- a/frontend/src/pages/chat/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from "react"; -import type { Message, Size2D } from "../../../../../core/types"; +import type { Message, Size2D } from "../../../../core/types"; import { EmojiMenu } from "./EmojiMenu"; interface MessageContextMenuProps { diff --git a/frontend/src/pages/chat/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx similarity index 97% rename from frontend/src/pages/chat/ui/components/chat/MessagePanelRenderer.tsx rename to frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index 8b6d8de..4c5ed43 100644 --- a/frontend/src/pages/chat/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -1,13 +1,13 @@ import { useState, useEffect, useRef } from "react"; import { useAppState } from "../../state"; -import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel"; +import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { ChatMessages } from "./ChatMessages"; import { ChatInputWrapper } from "./ChatInputWrapper"; -import { setGlobalMessageHandler } from "../../../../../core/websocket"; -import type { Message, WebSocketMessage } from "../../../../../core/types"; -import defaultAvatar from "../../../../../images/default-avatar.png"; -import AnimatedOpacity from "../core/animations/AnimatedOpacity"; -import type { DMPanel } from "../../panels/DMPanel"; +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 type { DMPanel } from "./panels/DMPanel"; interface MessagePanelRendererProps { panel: MessagePanel | null; diff --git a/frontend/src/pages/chat/ui/components/chat/RightPanel.tsx b/frontend/src/pages/chat/ui/right/RightPanel.tsx similarity index 100% rename from frontend/src/pages/chat/ui/components/chat/RightPanel.tsx rename to frontend/src/pages/chat/ui/right/RightPanel.tsx diff --git a/frontend/src/pages/chat/ui/components/chat/UserProfileDialog.tsx b/frontend/src/pages/chat/ui/right/UserProfileDialog.tsx similarity index 90% rename from frontend/src/pages/chat/ui/components/chat/UserProfileDialog.tsx rename to frontend/src/pages/chat/ui/right/UserProfileDialog.tsx index ef3eeeb..878ed1b 100644 --- a/frontend/src/pages/chat/ui/components/chat/UserProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/right/UserProfileDialog.tsx @@ -1,8 +1,8 @@ -import type { DialogProps } from "../../../../../core/types"; -import type { UserProfile } from "../../../../../core/types"; -import { MaterialDialog } from "../core/Dialog"; -import { formatTime } from "../../../../../utils/utils"; -import defaultAvatar from "../../../../../images/default-avatar.png"; +import type { DialogProps } from "../../../../core/types"; +import type { UserProfile } from "../../../../core/types"; +import { MaterialDialog } from "../../../../core/components/Dialog"; +import { formatTime } from "../../../../utils/utils"; +import defaultAvatar from "../../../../images/default-avatar.png"; interface UserProfileDialogProps extends DialogProps { userProfile: UserProfile | null; diff --git a/frontend/src/pages/chat/ui/components/chat/emojiData.ts b/frontend/src/pages/chat/ui/right/emojiData.ts similarity index 100% rename from frontend/src/pages/chat/ui/components/chat/emojiData.ts rename to frontend/src/pages/chat/ui/right/emojiData.ts diff --git a/frontend/src/pages/chat/ui/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts similarity index 98% rename from frontend/src/pages/chat/ui/panels/DMPanel.ts rename to frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 8dc8871..b8e4aa6 100644 --- a/frontend/src/pages/chat/ui/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -6,9 +6,9 @@ import { sendDmWithFiles, editDmEnvelope, deleteDmEnvelope -} from "../../../../core/api/dmApi"; -import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../../../core/types"; -import type { UserState } from "../state"; +} from "../../../../../core/api/dmApi"; +import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../../../../core/types"; +import type { UserState } from "../../../state"; export interface DMPanelData { userId: number; diff --git a/frontend/src/pages/chat/ui/panels/MessagePanel.ts b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts similarity index 98% rename from frontend/src/pages/chat/ui/panels/MessagePanel.ts rename to frontend/src/pages/chat/ui/right/panels/MessagePanel.ts index f8b44cc..c291d03 100644 --- a/frontend/src/pages/chat/ui/panels/MessagePanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts @@ -1,5 +1,5 @@ -import type { Message, WebSocketMessage } from "../../../../core/types"; -import type { UserState } from "../state"; +import type { Message, WebSocketMessage } from "../../../../../core/types"; +import type { UserState } from "../../../state"; export interface MessagePanelState { id: string; diff --git a/frontend/src/pages/chat/ui/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts similarity index 95% rename from frontend/src/pages/chat/ui/panels/PublicChatPanel.ts rename to frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index a1bf903..7f9c085 100644 --- a/frontend/src/pages/chat/ui/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -1,9 +1,9 @@ import { MessagePanel } from "./MessagePanel"; -import { API_BASE_URL } from "../../../../core/config"; -import { getAuthHeaders } from "../../../../core/api/authApi"; -import { request } from "../../../../core/websocket"; -import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../../../core/types"; -import type { UserState } from "../state"; +import { API_BASE_URL } from "../../../../../core/config"; +import { getAuthHeaders } from "../../../../../core/api/authApi"; +import { request } from "../../../../../core/websocket"; +import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../../../../core/types"; +import type { UserState } from "../../../state"; export class PublicChatPanel extends MessagePanel { private messagesLoaded: boolean = false; diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index f6311f2..3df3874 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -1,5 +1,5 @@ import { Navigate, useNavigate } from "react-router-dom"; -import { useAppState } from "../chat/ui/state"; +import { useAppState } from "../chat/state"; import { isElectron } from "../../core/electron/electron"; import "./home.scss";