From 04d3e4d9952b3cc3c466520168d99f1fc13ed7fd Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Sep 2025 21:41:48 +0300 Subject: [PATCH 1/9] Start DMs --- .cursor/rules/general.mdc | 3 +- backend/routes/account.py | 2 + frontend/src/api/dmApi.ts | 122 +++++++ frontend/src/hooks/useDM.ts | 341 ++++++++++++++++++ frontend/src/ui/components/chat/DMPanel.tsx | 128 +++++++ .../src/ui/components/chat/DMUsersList.tsx | 69 ++++ frontend/src/ui/components/chat/LeftPanel.tsx | 13 +- .../ui/components/chat/MessageContextMenu.tsx | 2 - .../src/ui/components/chat/RightPanel.tsx | 17 +- frontend/src/ui/state.ts | 3 +- 10 files changed, 691 insertions(+), 9 deletions(-) create mode 100644 frontend/src/api/dmApi.ts create mode 100644 frontend/src/hooks/useDM.ts create mode 100644 frontend/src/ui/components/chat/DMPanel.tsx create mode 100644 frontend/src/ui/components/chat/DMUsersList.tsx diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 52b590d..c0c8ab0 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -11,4 +11,5 @@ When working with this project, follow these rules: - To typecheck, run "npm run frontend:typecheck". - To build, run "npm run frontend:build". -- Do NOT "cd" to the project directory. \ No newline at end of file +- Do NOT "cd" to the project directory. +- If possible, try to update files in a single edit. \ No newline at end of file diff --git a/backend/routes/account.py b/backend/routes/account.py index 5b51f54..037873e 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -17,6 +17,8 @@ def convert_user(user: User) -> dict: "last_seen": user.last_seen.isoformat(), "online": user.online, "username": user.username, + "profile_picture": user.profile_picture, + "bio": user.bio, "admin": user.username == OWNER_USERNAME } diff --git a/frontend/src/api/dmApi.ts b/frontend/src/api/dmApi.ts new file mode 100644 index 0000000..612d887 --- /dev/null +++ b/frontend/src/api/dmApi.ts @@ -0,0 +1,122 @@ +import { API_BASE_URL } from "../core/config"; +import { getAuthHeaders } from "../auth/api"; +import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; +import { randomBytes } from "../crypto/kdf"; +import { getCurrentKeys } from "../auth/crypto"; +import { request } from "../websocket"; +import type { FetchDMResponse, SendDMRequest, DmEnvelope, User } from "../core/types"; +import { b64, ub64 } from "../utils/utils"; + +export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string, token: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); + const wrap = await aesGcmEncrypt(wk, mk); + + await fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + }) + }); +} + +export async function fetchDm(since: number | undefined, token: string): Promise { + const url = new URL(`${API_BASE_URL}/dm/fetch`); + if (since) url.searchParams.set("since", String(since)); + + const response = await fetch(url, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + const data: FetchDMResponse = await response.json(); + return data.messages ?? []; + } else { + return []; + } +} + +export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Obtain the key + const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); + + // Decrypt + const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); + return new TextDecoder().decode(msg); +} + +export async function fetchUsers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + +export async function fetchUserPublicKey(userId: number, token: string): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return null; + const data = await res.json(); + return data.publicKey; +} + +export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return []; + const data = await response.json(); + return data.messages || []; +} + +export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Encryption key + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Encrypt the message + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); + const wrap = await aesGcmEncrypt(wk, mk); + + const payload: SendDMRequest = { + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + }; + + await request({ + type: "dmSend", + credentials: { + scheme: "Bearer", + credentials: authToken + }, + data: payload + }); +} diff --git a/frontend/src/hooks/useDM.ts b/frontend/src/hooks/useDM.ts new file mode 100644 index 0000000..5167d68 --- /dev/null +++ b/frontend/src/hooks/useDM.ts @@ -0,0 +1,341 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppState } from "../ui/state"; +import { + fetchUsers, + fetchUserPublicKey, + fetchDMHistory, + decryptDm, + sendDMViaWebSocket +} from "../api/dmApi"; +import type { User, Message } from "../core/types"; +import { websocket } from "../websocket"; + +interface DMUser extends User { + lastMessage?: string; + unreadCount: number; + publicKey?: string | null; +} + +export function useDM() { + const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState(); + const [dmUsers, setDmUsersState] = useState([]); + const [isLoadingUsers, setIsLoadingUsers] = useState(false); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + const usersLoadedRef = useRef(false); + + // Load last message and unread count for a specific user + const loadUserLastMessage = useCallback(async (dmUser: DMUser) => { + if (!user.authToken) return; + + try { + // Get public key + const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); + if (!publicKey) return; + + // Get message history + const messages = await fetchDMHistory(dmUser.id, user.authToken, 50); + if (messages.length === 0) return; + + // Find last message + const lastMessage = messages[messages.length - 1]; + let lastPlaintext: string | null = null; + + try { + lastPlaintext = await decryptDm(lastMessage, publicKey); + } catch (error) { + console.error("Failed to decrypt last message:", error); + } + + // Calculate unread count + const lastReadId = getLastReadId(dmUser.id); + let unreadCount = 0; + for (const msg of messages) { + if (msg.senderId === dmUser.id && msg.id > lastReadId) { + unreadCount++; + } + } + + // Update user state + setDmUsersState(prev => prev.map(u => + u.id === dmUser.id + ? { + ...u, + lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined, + unreadCount, + publicKey + } + : u + )); + } catch (error) { + console.error("Failed to load last message for user:", dmUser.id, error); + } + }, [user.authToken]); + + // Load users when DM tab is active + const loadUsers = useCallback(async () => { + if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return; + + usersLoadedRef.current = true; + setIsLoadingUsers(true); + try { + const users = await fetchUsers(user.authToken); + console.log("Fetched users:", users); + const dmUsersWithState: DMUser[] = users.map(user => ({ + ...user, + unreadCount: 0, + lastMessage: undefined, + publicKey: null + })); + + setDmUsersState(dmUsersWithState); + setDmUsers(users); + + // Load last messages and unread counts for visible users + // Call loadUserLastMessage directly without dependency + for (const dmUser of dmUsersWithState) { + if (!user.authToken) continue; + + try { + // Get public key + const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); + if (!publicKey) continue; + + // Get message history + const messages = await fetchDMHistory(dmUser.id, user.authToken, 50); + if (messages.length === 0) continue; + + // Find last message + const lastMessage = messages[messages.length - 1]; + let lastPlaintext: string | null = null; + + try { + lastPlaintext = await decryptDm(lastMessage, publicKey); + } catch (error) { + console.error("Failed to decrypt last message:", error); + } + + // Calculate unread count + const lastReadId = getLastReadId(dmUser.id); + let unreadCount = 0; + for (const msg of messages) { + if (msg.senderId === dmUser.id && msg.id > lastReadId) { + unreadCount++; + } + } + + // Update user state + setDmUsersState(prev => prev.map(u => + u.id === dmUser.id + ? { + ...u, + lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined, + unreadCount, + publicKey + } + : u + )); + } catch (error) { + console.error("Failed to load last message for user:", dmUser.id, error); + } + } + } catch (error) { + console.error("Failed to load DM users:", error); + } finally { + setIsLoadingUsers(false); + } + }, [user.authToken, isLoadingUsers]); + + // Reset users loaded flag when user changes + useEffect(() => { + usersLoadedRef.current = false; + }, [user.authToken]); + + // Load DM history for active conversation + const loadDMHistory = useCallback(async (userId: number, publicKey: string) => { + if (!user.authToken || isLoadingHistory) return; + + setIsLoadingHistory(true); + try { + const messages = await fetchDMHistory(userId, user.authToken, 50); + const decryptedMessages: Message[] = []; + let maxIncomingId = 0; + + for (const env of messages) { + try { + const text = await decryptDm(env, publicKey); + const isAuthor = env.senderId !== userId; + const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User"; + + decryptedMessages.push({ + id: env.id, + content: text, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false + }); + + if (env.senderId === userId && env.id > maxIncomingId) { + maxIncomingId = env.id; + } + } catch (error) { + console.error("Error decrypting message:", error); + } + } + + clearMessages(); + decryptedMessages.forEach(msg => addMessage(msg)); + + // Update last read ID + if (maxIncomingId > 0) { + setLastReadId(userId, maxIncomingId); + // Clear unread count + setDmUsersState(prev => prev.map(u => + u.id === userId ? { ...u, unreadCount: 0 } : u + )); + } + } catch (error) { + console.error("Failed to load DM history:", error); + } finally { + setIsLoadingHistory(false); + } + }, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]); + + // Send DM message + const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => { + if (!user.authToken) return; + + try { + await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken); + } catch (error) { + console.error("Failed to send DM:", error); + } + }, [user.authToken]); + + // Start DM conversation + const startDMConversation = useCallback(async (dmUser: DMUser) => { + if (!user.authToken) return; + + try { + // Get public key if not already loaded + let publicKey = dmUser.publicKey; + if (!publicKey) { + publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); + if (!publicKey) return; + } + + // Set active DM + setActiveDm({ + userId: dmUser.id, + username: dmUser.username, + publicKey + }); + + // Load conversation history + await loadDMHistory(dmUser.id, publicKey); + } catch (error) { + console.error("Failed to start DM conversation:", error); + } + }, [user.authToken, setActiveDm, loadDMHistory]); + + // WebSocket message handler + useEffect(() => { + const handleWebSocketMessage = async (e: MessageEvent) => { + try { + const msg = JSON.parse(e.data); + if (msg.type === "dmNew") { + const { senderId, recipientId, ...envelope } = msg.data; + + // If this is for the active DM conversation + if (chat.activeDm && (senderId === chat.activeDm.userId || recipientId === chat.activeDm.userId)) { + try { + const plaintext = await decryptDm(envelope, chat.activeDm.publicKey!); + const isAuthor = senderId !== chat.activeDm.userId; + + addMessage({ + id: envelope.id, + content: plaintext, + username: isAuthor ? (user.currentUser?.username || "Unknown") : (chat.activeDm.username || "Unknown"), + timestamp: envelope.timestamp, + is_read: false, + is_edited: false + }); + + // Update last read if it's from the other user + if (senderId === chat.activeDm.userId) { + setLastReadId(chat.activeDm.userId, Math.max(getLastReadId(chat.activeDm.userId), envelope.id)); + } + } catch (error) { + console.error("Failed to decrypt incoming DM:", error); + } + } else { + // Update unread count for other users + const otherUserId = senderId; + setDmUsersState(prev => prev.map(u => + u.id === otherUserId + ? { ...u, unreadCount: u.unreadCount + 1 } + : u + )); + + // Update last message preview + try { + const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + if (publicKey) { + const plaintext = await decryptDm(envelope, publicKey); + setDmUsersState(prev => prev.map(u => + u.id === otherUserId + ? { + ...u, + lastMessage: plaintext.split(/\r?\n/).slice(0, 2).join("\n"), + publicKey + } + : u + )); + } + } catch (error) { + console.error("Failed to update last message preview:", error); + } + } + } + } catch (error) { + console.error("Failed to handle WebSocket message:", error); + } + }; + + websocket.addEventListener("message", handleWebSocketMessage); + return () => websocket.removeEventListener("message", handleWebSocketMessage); + }, [chat.activeDm, user.currentUser, addMessage]); + + // Force reload users (useful for refreshing the list) + const reloadUsers = useCallback(() => { + usersLoadedRef.current = false; + loadUsers(); + }, [loadUsers]); + + return { + dmUsers, + isLoadingUsers, + isLoadingHistory, + loadUsers, + reloadUsers, + startDMConversation, + sendDMMessage, + loadUserLastMessage + }; +} + +// Helper functions for localStorage +function getLastReadId(userId: number): number { + try { + const v = localStorage.getItem(`dmLastRead:${userId}`); + return v ? Number(v) : 0; + } catch { + return 0; + } +} + +function setLastReadId(userId: number, id: number): void { + try { + localStorage.setItem(`dmLastRead:${userId}`, String(id)); + } catch {} +} diff --git a/frontend/src/ui/components/chat/DMPanel.tsx b/frontend/src/ui/components/chat/DMPanel.tsx new file mode 100644 index 0000000..54cd125 --- /dev/null +++ b/frontend/src/ui/components/chat/DMPanel.tsx @@ -0,0 +1,128 @@ +import { useState, useEffect, useRef } from "react"; +import { useAppState } from "../../state"; +import { useDM } from "../../../hooks/useDM"; +import { ChatMessages } from "./ChatMessages"; +import defaultAvatar from "../../../resources/images/default-avatar.png"; + +export function DMPanel() { + const { chat } = useAppState(); + const { sendDMMessage, isLoadingHistory } = useDM(); + const [message, setMessage] = useState(""); + const messagesEndRef = useRef(null); + + const activeDm = chat.activeDm; + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [chat.messages]); + + const handleSendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (!message.trim() || !activeDm?.publicKey) return; + + try { + await sendDMMessage(activeDm.userId, activeDm.publicKey, message); + setMessage(""); + } catch (error) { + console.error("Failed to send DM:", error); + } + }; + + const handleProfileClick = () => { + // TODO: Implement profile dialog for DM user + console.log("Profile clicked for DM user:", activeDm?.username); + }; + + if (!activeDm) { + return ( +
+
+ Avatar +
+
+

Выберите пользователя

+

+ + Выберите пользователя для начала разговора +

+
+
+
+
+
+ Выберите пользователя из списка для начала личных сообщений +
+
+
+ ); + } + + return ( +
+
+ Avatar +
+
+

{activeDm.username}

+

+ + Личные сообщения +

+
+ Свернуть чат +
+
+ +
+ {isLoadingHistory ? ( +
+ Загрузка сообщений... +
+ ) : ( + <> + +
+ + )} +
+ +
+
+
+ setMessage(e.target.value)} + /> + +
+
+
+
+ ); +} diff --git a/frontend/src/ui/components/chat/DMUsersList.tsx b/frontend/src/ui/components/chat/DMUsersList.tsx new file mode 100644 index 0000000..055c4bb --- /dev/null +++ b/frontend/src/ui/components/chat/DMUsersList.tsx @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import { useDM } from "../../../hooks/useDM"; +import { useAppState } from "../../state"; +import defaultAvatar from "../../../resources/images/default-avatar.png"; + +export function DMUsersList() { + const { dmUsers, isLoadingUsers, loadUsers, startDMConversation } = useDM(); + const { chat } = useAppState(); + + useEffect(() => { + if (chat.activeTab === "dms") { + loadUsers(); + } + }, [chat.activeTab, loadUsers]); + + if (isLoadingUsers) { + return ( + + + + + + ); + } + + if (dmUsers.length === 0) { + return ( + + + + + + ); + } + + return ( + + {dmUsers.map((user) => ( + startDMConversation(user)} + style={{ cursor: "pointer" }} + > + {user.username} { + (e.target as HTMLImageElement).src = defaultAvatar; + }} + /> + {user.unreadCount > 0 && ( + + {user.unreadCount} + + )} + + ))} + + ); +} diff --git a/frontend/src/ui/components/chat/LeftPanel.tsx b/frontend/src/ui/components/chat/LeftPanel.tsx index f56756c..2b869d2 100644 --- a/frontend/src/ui/components/chat/LeftPanel.tsx +++ b/frontend/src/ui/components/chat/LeftPanel.tsx @@ -2,9 +2,12 @@ import { PRODUCT_NAME } from "../../../core/config"; import { useDialog } from "../../contexts/DialogContext"; import { useChat } from "../../hooks/useChat"; import defaultAvatar from "../../../resources/images/default-avatar.png"; -import { useState } from "react"; +import { useState, type FormEvent } from "react"; import { ProfileDialog } from "../profile/ProfileDialog"; import { SettingsDialog } from "../settings/SettingsDialog"; +import { DMUsersList } from "./DMUsersList"; +import type { Tabs } from "mdui"; +import type { ChatTabs } from "../../state"; function BottomAppBar() { const [settingsOpen, onSettingsOpenChange] = useState(false); @@ -30,9 +33,13 @@ function ChatTabs() { setCurrentChat(chatName); }; + const handleTabChange = (e: FormEvent) => { + setActiveTab((e.target as Tabs).value as ChatTabs); + }; + return (
- setActiveTab(e.value)}> + Чаты Каналы Контакты @@ -63,7 +70,7 @@ function ChatTabs() { Скоро будет... Скоро будет... - +
diff --git a/frontend/src/ui/components/chat/MessageContextMenu.tsx b/frontend/src/ui/components/chat/MessageContextMenu.tsx index 3fad2f5..e8c1883 100644 --- a/frontend/src/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/ui/components/chat/MessageContextMenu.tsx @@ -30,8 +30,6 @@ export function MessageContextMenu({ isOpen, onOpenChange }: MessageContextMenuProps) { - console.log("MessageContextMenu rendered with position:", position, "message:", message.id); - // Internal state for dialogs and closing animation const [editDialogOpen, setEditDialogOpen] = useState(false); const [replyDialogOpen, setReplyDialogOpen] = useState(false); diff --git a/frontend/src/ui/components/chat/RightPanel.tsx b/frontend/src/ui/components/chat/RightPanel.tsx index f6891d9..46811ad 100644 --- a/frontend/src/ui/components/chat/RightPanel.tsx +++ b/frontend/src/ui/components/chat/RightPanel.tsx @@ -1,12 +1,15 @@ import { useEffect, useState } from "react"; import { useChat } from "../../hooks/useChat"; +import { useAppState } from "../../state"; import { ChatInputWrapper } from "./ChatInputWrapper"; import { ChatMainHeader } from "./ChatMainHeader"; import { ChatMessages } from "./ChatMessages"; +import { DMPanel } from "./DMPanel"; import { delay } from "../../../utils/utils"; export function RightPanel() { const { isChatSwitching } = useChat(); + const { chat } = useAppState(); const [switchIn, setSwitchIn] = useState(false); const [switchOut, setSwitchOut] = useState(false); @@ -23,13 +26,23 @@ export function RightPanel() { })(); }, [isChatSwitching]) - return ( -
+ let content: React.ReactNode; + + if (chat.activeTab === "dms") { + content = + } else { + content = (
+ ) + } + + return ( +
+ {content}
); } diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index cd67cdd..93ca344 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -3,6 +3,7 @@ import type { Message, User, WebSocketMessage } from "../core/types"; import { request } from "../websocket"; type Page = "login" | "register" | "chat" +export type ChatTabs = "chats" | "channels" | "contacts" | "dms" interface ActiveDM { userId: number; @@ -13,7 +14,7 @@ interface ActiveDM { interface ChatState { messages: Message[]; currentChat: string; - activeTab: "chats" | "channels" | "contacts" | "dms"; + activeTab: ChatTabs; dmUsers: User[]; activeDm: ActiveDM | null; isChatSwitching: boolean; From cad42e158493f0b90e53aa2c61df0726ab582750 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Sep 2025 22:36:24 +0300 Subject: [PATCH 2/9] Implement chat panels --- .cursor/rules/general.mdc | 2 + .../ui/components/chat/ChatInputWrapper.tsx | 12 +- .../src/ui/components/chat/ChatMessages.tsx | 11 +- .../src/ui/components/chat/DMUsersList.tsx | 34 +++- frontend/src/ui/components/chat/LeftPanel.tsx | 13 +- .../components/chat/MessagePanelRenderer.tsx | 132 +++++++++++++ .../src/ui/components/chat/RightPanel.tsx | 45 +---- frontend/src/ui/panels/DMPanel.ts | 179 ++++++++++++++++++ frontend/src/ui/panels/MessagePanel.ts | 131 +++++++++++++ frontend/src/ui/panels/PublicChatPanel.ts | 123 ++++++++++++ frontend/src/ui/state.ts | 148 ++++++++++++++- 11 files changed, 775 insertions(+), 55 deletions(-) create mode 100644 frontend/src/ui/components/chat/MessagePanelRenderer.tsx create mode 100644 frontend/src/ui/panels/DMPanel.ts create mode 100644 frontend/src/ui/panels/MessagePanel.ts create mode 100644 frontend/src/ui/panels/PublicChatPanel.ts diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index c0c8ab0..d6bd4c8 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -11,5 +11,7 @@ When working with this project, follow these rules: - To typecheck, run "npm run frontend:typecheck". - To build, run "npm run frontend:build". + + Do NOT execute other commands like "cd". - Do NOT "cd" to the project directory. - If possible, try to update files in a single edit. \ No newline at end of file diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 228e8b2..8101b87 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -1,14 +1,22 @@ import { useState } from "react"; import { useChat } from "../../hooks/useChat"; -export function ChatInputWrapper() { +interface ChatInputWrapperProps { + onSendMessage?: (message: string) => void; +} + +export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) { const [message, setMessage] = useState(""); const { sendMessage } = useChat(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (message.trim()) { - await sendMessage(message); + if (onSendMessage) { + onSendMessage(message); + } else { + await sendMessage(message); + } setMessage(""); } }; diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index 603aa9b..45aca19 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -10,9 +10,16 @@ import { useState } from "react"; import { delay } from "../../../utils/utils"; import { request } from "../../../websocket"; -export function ChatMessages() { - const { messages } = useChat(); +interface ChatMessagesProps { + messages?: MessageType[]; +} + +export function ChatMessages({ messages: propMessages }: ChatMessagesProps) { + const { messages: hookMessages } = useChat(); const { user } = useAppState(); + + // Use prop messages if provided, otherwise use hook messages + const messages = propMessages || hookMessages; const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [selectedUserProfile, setSelectedUserProfile] = useState(null); const [isLoadingProfile, setIsLoadingProfile] = useState(false); diff --git a/frontend/src/ui/components/chat/DMUsersList.tsx b/frontend/src/ui/components/chat/DMUsersList.tsx index 055c4bb..28b8c8f 100644 --- a/frontend/src/ui/components/chat/DMUsersList.tsx +++ b/frontend/src/ui/components/chat/DMUsersList.tsx @@ -1,11 +1,12 @@ import { useEffect } from "react"; import { useDM } from "../../../hooks/useDM"; import { useAppState } from "../../state"; +import { fetchUserPublicKey } from "../../../api/dmApi"; import defaultAvatar from "../../../resources/images/default-avatar.png"; export function DMUsersList() { - const { dmUsers, isLoadingUsers, loadUsers, startDMConversation } = useDM(); - const { chat } = useAppState(); + const { dmUsers, isLoadingUsers, loadUsers } = useDM(); + const { chat, switchToDM } = useAppState(); useEffect(() => { if (chat.activeTab === "dms") { @@ -33,6 +34,33 @@ export function DMUsersList() { ); } + const handleUserClick = async (user: any) => { + if (!user.publicKey) { + // Get public key if not already loaded + const authToken = useAppState.getState().user.authToken; + if (!authToken) { + console.error("No auth token available"); + return; + } + + const publicKey = await fetchUserPublicKey(user.id, authToken); + if (publicKey) { + user.publicKey = publicKey; + } else { + console.error("Failed to get public key for user:", user.id); + return; + } + } + + await switchToDM({ + userId: user.id, + username: user.username, + publicKey: user.publicKey, + profilePicture: user.profile_picture, + online: user.online || false + }); + }; + return ( {dmUsers.map((user) => ( @@ -40,7 +68,7 @@ export function DMUsersList() { key={user.id} headline={user.username} description={user.lastMessage || "Нет сообщений"} - onClick={() => startDMConversation(user)} + onClick={() => handleUserClick(user)} style={{ cursor: "pointer" }} > { - setCurrentChat(chatName); + const handleChatClick = async (chatName: string) => { + await switchToPublicChat(chatName); }; - const handleTabChange = (e: FormEvent) => { - setActiveTab((e.target as Tabs).value as ChatTabs); + const handleTabChange = async (e: FormEvent) => { + const tab = (e.target as Tabs).value as ChatTabs; + await switchToTab(tab); }; return ( diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx new file mode 100644 index 0000000..4887c9c --- /dev/null +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect, useRef } from "react"; +import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel"; +import { ChatMainHeader } from "./ChatMainHeader"; +import { ChatMessages } from "./ChatMessages"; +import { ChatInputWrapper } from "./ChatInputWrapper"; +import defaultAvatar from "../../../resources/images/default-avatar.png"; + +interface MessagePanelRendererProps { + panel: MessagePanel | null; + isChatSwitching: boolean; +} + +export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRendererProps) { + const [panelState, setPanelState] = useState(null); + const [switchIn, setSwitchIn] = useState(false); + const [switchOut, setSwitchOut] = useState(false); + const messagesEndRef = useRef(null); + + // Handle panel state changes + useEffect(() => { + if (panel) { + setPanelState(panel.getState()); + + // Set up state change listener + const handleStateChange = (newState: MessagePanelState) => { + setPanelState(newState); + }; + + // Store the handler for cleanup + (panel as any).onStateChange = handleStateChange; + } else { + setPanelState(null); + } + }, [panel]); + + // Handle chat switching animation + useEffect(() => { + if (isChatSwitching) { + setSwitchOut(true); + setTimeout(() => { + setSwitchOut(false); + setSwitchIn(true); + setTimeout(() => setSwitchIn(false), 200); + }, 250); + } + }, [isChatSwitching]); + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [panelState?.messages]); + + if (!panel || !panelState) { + return ( +
+
+
+ Avatar +
+
+

Select a chat

+

+ + Choose a chat to start messaging +

+
+
+
+
+
+ Select a chat from the sidebar to start messaging +
+
+
+
+ ); + } + + return ( +
+
+
+ Avatar +
+
+

{panelState.title}

+

+ + {panelState.online ? "Online" : "Offline"} + {panelState.isTyping && " • Typing..."} +

+
+ Свернуть чат +
+
+ +
+ {panelState.isLoading ? ( +
+ Loading messages... +
+ ) : ( + <> + +
+ + )} +
+ + +
+
+ ); +} diff --git a/frontend/src/ui/components/chat/RightPanel.tsx b/frontend/src/ui/components/chat/RightPanel.tsx index 46811ad..ece779f 100644 --- a/frontend/src/ui/components/chat/RightPanel.tsx +++ b/frontend/src/ui/components/chat/RightPanel.tsx @@ -1,48 +1,13 @@ -import { useEffect, useState } from "react"; -import { useChat } from "../../hooks/useChat"; import { useAppState } from "../../state"; -import { ChatInputWrapper } from "./ChatInputWrapper"; -import { ChatMainHeader } from "./ChatMainHeader"; -import { ChatMessages } from "./ChatMessages"; -import { DMPanel } from "./DMPanel"; -import { delay } from "../../../utils/utils"; +import { MessagePanelRenderer } from "./MessagePanelRenderer"; export function RightPanel() { - const { isChatSwitching } = useChat(); const { chat } = useAppState(); - const [switchIn, setSwitchIn] = useState(false); - const [switchOut, setSwitchOut] = useState(false); - - useEffect(() => { - (async () => { - if (isChatSwitching) { - setSwitchOut(true); - } else { - setSwitchIn(true); - await delay(200); - setSwitchIn(false); - setSwitchOut(false); - } - })(); - }, [isChatSwitching]) - - let content: React.ReactNode; - - if (chat.activeTab === "dms") { - content = - } else { - content = ( -
- - - -
- ) - } return ( -
- {content} -
+ ); } diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts new file mode 100644 index 0000000..af52a70 --- /dev/null +++ b/frontend/src/ui/panels/DMPanel.ts @@ -0,0 +1,179 @@ +import { MessagePanel, type MessagePanelCallbacks } from "./MessagePanel"; +import { + fetchUserPublicKey, + fetchDMHistory, + decryptDm, + sendDMViaWebSocket +} from "../../api/dmApi"; +import type { Message, DmEnvelope } from "../../core/types"; +import type { UserState } from "../state"; + +export interface DMPanelData { + userId: number; + username: string; + publicKey: string; + profilePicture?: string; + online: boolean; +} + +export class DMPanel extends MessagePanel { + private dmData: DMPanelData | null = null; + private messagesLoaded: boolean = false; + + constructor( + user: UserState, + callbacks: MessagePanelCallbacks, + onStateChange: (state: any) => void + ) { + super("dm", user, callbacks, onStateChange); + } + + async activate(): Promise { + if (this.dmData && !this.messagesLoaded) { + await this.loadMessages(); + } + } + + deactivate(): void { + // DM doesn't need special cleanup + } + + async loadMessages(): Promise { + if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return; + + this.setLoading(true); + try { + const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50); + const decryptedMessages: Message[] = []; + let maxIncomingId = 0; + + for (const env of messages) { + try { + const text = await decryptDm(env, this.dmData!.publicKey); + const isAuthor = env.senderId !== this.dmData!.userId; + const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username; + + decryptedMessages.push({ + id: env.id, + content: text, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false + }); + + if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) { + maxIncomingId = env.id; + } + } catch (error) { + console.error("Error decrypting message:", error); + } + } + + this.clearMessages(); + decryptedMessages.forEach(msg => this.addMessage(msg)); + + // Update last read ID + if (maxIncomingId > 0) { + this.setLastReadId(this.dmData.userId, maxIncomingId); + } + this.messagesLoaded = true; + } catch (error) { + console.error("Failed to load DM history:", error); + } finally { + this.setLoading(false); + } + } + + async sendMessage(content: string): Promise { + if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; + + try { + await sendDMViaWebSocket( + this.dmData.userId, + this.dmData.publicKey, + content, + this.currentUser.authToken + ); + } catch (error) { + console.error("Failed to send DM:", error); + } + } + + // Set DM conversation data + setDMData(dmData: DMPanelData): void { + this.dmData = dmData; + this.messagesLoaded = false; + this.updateState({ + id: `dm-${dmData.userId}`, + title: dmData.username, + profilePicture: dmData.profilePicture, + online: dmData.online + }); + } + + // Handle incoming WebSocket DM messages + handleWebSocketMessage = async (response: any): Promise => { + if (response.type === "dmNew" && this.dmData) { + const { senderId, recipientId, ...envelope } = response.data; + + // If this is for the active DM conversation + if (senderId === this.dmData.userId || recipientId === this.dmData.userId) { + try { + const plaintext = await decryptDm(envelope, this.dmData.publicKey); + const isAuthor = senderId !== this.dmData.userId; + + this.addMessage({ + id: envelope.id, + content: plaintext, + username: isAuthor ? "You" : this.dmData.username, + timestamp: envelope.timestamp, + is_read: false, + is_edited: false + }); + + // Update last read if it's from the other user + if (senderId === this.dmData.userId) { + this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id)); + } + } catch (error) { + console.error("Failed to decrypt incoming DM:", error); + } + } + } + }; + + // Reset for DM switching + reset(): void { + this.dmData = null; + this.messagesLoaded = false; + this.clearMessages(); + this.updateState({ + id: "dm", + title: "Select a user", + profilePicture: undefined, + online: false + }); + } + + // Update auth token + setAuthToken(authToken: string): void { + this.currentUser.authToken = authToken; + } + + // Helper functions for localStorage + private getLastReadId(userId: number): number { + try { + const v = localStorage.getItem(`dmLastRead:${userId}`); + return v ? Number(v) : 0; + } catch { + return 0; + } + } + + private setLastReadId(userId: number, id: number): void { + try { + localStorage.setItem(`dmLastRead:${userId}`, String(id)); + } catch {} + } +} diff --git a/frontend/src/ui/panels/MessagePanel.ts b/frontend/src/ui/panels/MessagePanel.ts new file mode 100644 index 0000000..0fd3a75 --- /dev/null +++ b/frontend/src/ui/panels/MessagePanel.ts @@ -0,0 +1,131 @@ +import type { User, Message } from "../../core/types"; +import type { UserState } from "../state"; + +export interface MessagePanelState { + id: string; + title: string; + profilePicture?: string; + online: boolean; + messages: Message[]; + isLoading: boolean; + isTyping: boolean; +} + +export interface MessagePanelCallbacks { + onSendMessage: (content: string) => void; + onEditMessage: (messageId: number, content: string) => void; + onDeleteMessage: (messageId: number) => void; + onReplyToMessage: (messageId: number, content: string) => void; + onProfileClick: () => void; +} + +export abstract class MessagePanel { + protected state: MessagePanelState; + protected callbacks: MessagePanelCallbacks; + protected onStateChange: (state: MessagePanelState) => void; + protected currentUser: UserState; + + constructor( + id: string, + currentUser: UserState, + callbacks: MessagePanelCallbacks, + onStateChange: (state: MessagePanelState) => void + ) { + this.state = { + id, + title: "", + online: false, + messages: [], + isLoading: false, + isTyping: false + }; + this.currentUser = currentUser; + this.callbacks = callbacks; + this.onStateChange = onStateChange; + } + + // Abstract methods that must be implemented by subclasses + abstract activate(): Promise; + abstract deactivate(): void; + abstract loadMessages(): Promise; + abstract sendMessage(content: string): Promise; + + // Common methods + protected updateState(updates: Partial): void { + this.state = { ...this.state, ...updates }; + this.onStateChange(this.state); + } + + protected addMessage(message: Message): void { + const messageExists = this.state.messages.some(msg => msg.id === message.id); + if (!messageExists) { + this.updateState({ + messages: [...this.state.messages, message] + }); + } + } + + protected updateMessage(messageId: number, updates: Partial): void { + this.updateState({ + messages: this.state.messages.map(msg => + msg.id === messageId ? { ...msg, ...updates } : msg + ) + }); + } + + protected removeMessage(messageId: number): void { + this.updateState({ + messages: this.state.messages.filter(msg => msg.id !== messageId) + }); + } + + protected clearMessages(): void { + this.updateState({ messages: [] }); + } + + protected setLoading(loading: boolean): void { + this.updateState({ isLoading: loading }); + } + + protected setTyping(typing: boolean): void { + this.updateState({ isTyping: typing }); + } + + // Getters + getState(): MessagePanelState { + return { ...this.state }; + } + + getId(): string { + return this.state.id; + } + + getTitle(): string { + return this.state.title; + } + + getMessages(): Message[] { + return [...this.state.messages]; + } + + // Event handlers + handleSendMessage = (content: string): void => { + this.sendMessage(content); + }; + + handleEditMessage = (messageId: number, content: string): void => { + this.callbacks.onEditMessage(messageId, content); + }; + + handleDeleteMessage = (messageId: number): void => { + this.callbacks.onDeleteMessage(messageId); + }; + + handleReplyToMessage = (messageId: number, content: string): void => { + this.callbacks.onReplyToMessage(messageId, content); + }; + + handleProfileClick = (): void => { + this.callbacks.onProfileClick(); + }; +} diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts new file mode 100644 index 0000000..09532de --- /dev/null +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -0,0 +1,123 @@ +import { MessagePanel, type MessagePanelCallbacks } from "./MessagePanel"; +import { API_BASE_URL } from "../../core/config"; +import { getAuthHeaders } from "../../auth/api"; +import { request } from "../../websocket"; +import type { Message, WebSocketMessage } from "../../core/types"; +import type { UserState } from "../state"; + +export class PublicChatPanel extends MessagePanel { + private chatName: string; + private messagesLoaded: boolean = false; + + constructor( + chatName: string, + currentUser: UserState, + callbacks: MessagePanelCallbacks, + onStateChange: (state: any) => void + ) { + super(`public-${chatName}`, currentUser, callbacks, onStateChange); + this.chatName = chatName; + this.updateState({ + title: chatName, + online: true // Public chats are always "online" + }); + } + + async activate(): Promise { + if (!this.messagesLoaded) { + await this.loadMessages(); + } + } + + deactivate(): void { + // Public chat doesn't need special cleanup + } + + async loadMessages(): Promise { + if (!this.currentUser.authToken || this.messagesLoaded) return; + + this.setLoading(true); + try { + const response = await fetch(`${API_BASE_URL}/get_messages`, { + headers: getAuthHeaders(this.currentUser.authToken) + }); + + if (response.ok) { + const data = await response.json(); + if (data.messages && data.messages.length > 0) { + this.clearMessages(); + data.messages.forEach((msg: Message) => { + this.addMessage(msg); + }); + } + } + this.messagesLoaded = true; + } catch (error) { + console.error("Error loading public chat messages:", error); + } finally { + this.setLoading(false); + } + } + + async sendMessage(content: string): Promise { + if (!this.currentUser.authToken || !content.trim()) return; + + try { + const response = await request({ + data: { content: content.trim() }, + credentials: { + scheme: "Bearer", + credentials: this.currentUser.authToken + }, + type: "sendMessage" + }); + + if (response.error) { + console.error("Error sending message:", response.error); + } + } catch (error) { + console.error("Error sending message:", error); + } + } + + // Handle incoming WebSocket messages + handleWebSocketMessage = (response: WebSocketMessage): void => { + switch (response.type) { + case 'messageEdited': + if (response.data) { + this.updateMessage(response.data.id, response.data); + } + break; + case 'messageDeleted': + if (response.data && response.data.message_id) { + this.removeMessage(response.data.message_id); + } + break; + case 'newMessage': + if (response.data) { + this.addMessage(response.data); + } + break; + } + }; + + // Reset for chat switching + reset(): void { + this.messagesLoaded = false; + this.clearMessages(); + } + + // Update chat name + setChatName(chatName: string): void { + this.chatName = chatName; + this.updateState({ + id: `public-${chatName}`, + title: chatName + }); + } + + // Update auth token + setAuthToken(authToken: string): void { + this.currentUser.authToken = authToken; + } +} \ No newline at end of file diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index 93ca344..1ef2486 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -1,6 +1,9 @@ import { create } from "zustand"; import type { Message, User, WebSocketMessage } from "../core/types"; import { request } from "../websocket"; +import { MessagePanel } from "./panels/MessagePanel"; +import { PublicChatPanel } from "./panels/PublicChatPanel"; +import { DMPanel, type DMPanelData } from "./panels/DMPanel"; type Page = "login" | "register" | "chat" export type ChatTabs = "chats" | "channels" | "contacts" | "dms" @@ -18,9 +21,12 @@ interface ChatState { dmUsers: User[]; activeDm: ActiveDM | null; isChatSwitching: boolean; + activePanel: MessagePanel | null; + publicChatPanel: PublicChatPanel | null; + dmPanel: DMPanel | null; } -interface UserState { +export interface UserState { currentUser: User | null; authToken: string | null; } @@ -40,6 +46,10 @@ interface AppState { setActiveDm: (dm: ChatState["activeDm"]) => void; clearMessages: () => void; setIsChatSwitching: (value: boolean) => void; + setActivePanel: (panel: MessagePanel | null) => void; + switchToPublicChat: (chatName: string) => Promise; + switchToDM: (dmData: DMPanelData) => Promise; + switchToTab: (tab: ChatTabs) => Promise; // User state user: UserState; @@ -58,7 +68,10 @@ export const useAppState = create((set, get) => ({ activeTab: "chats", dmUsers: [], activeDm: null, - isChatSwitching: false + isChatSwitching: false, + activePanel: null, + publicChatPanel: null, + dmPanel: null }, setIsChatSwitching: (value: boolean) => set((state) => ({ chat: { @@ -159,5 +172,134 @@ export const useAppState = create((set, get) => ({ authToken: null }, currentPage: "login" - })) + })), + + // Panel management + setActivePanel: (panel: MessagePanel | null) => set((state) => ({ + chat: { + ...state.chat, + activePanel: panel + } + })), + + switchToPublicChat: async (chatName: string) => { + const state = get(); + const { user, chat } = state; + + if (!user.authToken) return; + + // Start chat switching animation + state.setIsChatSwitching(true); + + // Create or get public chat panel + let publicChatPanel = chat.publicChatPanel; + if (!publicChatPanel) { + const callbacks = { + onSendMessage: (content: string) => {}, + onEditMessage: (messageId: number, content: string) => {}, + onDeleteMessage: (messageId: number) => {}, + onReplyToMessage: (messageId: number, content: string) => {}, + onProfileClick: () => {} + }; + + publicChatPanel = new PublicChatPanel( + chatName, + user, + callbacks, + () => {} // State change handled by MessagePanelRenderer + ); + } else { + publicChatPanel.setChatName(chatName); + publicChatPanel.setAuthToken(user.authToken); + } + + // Wait for animation + await new Promise(resolve => setTimeout(resolve, 250)); + + // Activate panel + await publicChatPanel.activate(); + + // Update state + set((state) => ({ + chat: { + ...state.chat, + activePanel: publicChatPanel, + publicChatPanel: publicChatPanel, + currentChat: chatName, + activeTab: "chats" + } + })); + + // End animation + state.setIsChatSwitching(false); + }, + + switchToDM: async (dmData: DMPanelData) => { + const state = get(); + const { user, chat } = state; + + if (!user.authToken) return; + + // Start chat switching animation + state.setIsChatSwitching(true); + + // Create or get DM panel + let dmPanel = chat.dmPanel; + if (!dmPanel) { + const callbacks = { + onSendMessage: (content: string) => {}, + onEditMessage: (messageId: number, content: string) => {}, + onDeleteMessage: (messageId: number) => {}, + onReplyToMessage: (messageId: number, content: string) => {}, + onProfileClick: () => {} + }; + + dmPanel = new DMPanel( + user, + callbacks, + () => {} // State change handled by MessagePanelRenderer + ); + } else { + dmPanel.setAuthToken(user.authToken); + } + + // Set DM data + dmPanel.setDMData(dmData); + + // Wait for animation + await new Promise(resolve => setTimeout(resolve, 250)); + + // Activate panel + await dmPanel.activate(); + + // Update state + set((state) => ({ + chat: { + ...state.chat, + activePanel: dmPanel, + dmPanel: dmPanel, + activeDm: { + userId: dmData.userId, + username: dmData.username, + publicKey: dmData.publicKey + }, + activeTab: "dms" + } + })); + + // End animation + state.setIsChatSwitching(false); + }, + + switchToTab: async (tab: ChatTabs) => { + const state = get(); + state.setActiveTab(tab); + + if (tab === "chats") { + await state.switchToPublicChat("Общий чат"); + } else if (tab === "dms") { + // DM tab - no specific panel until user is selected + state.setActivePanel(null); + } + } })); \ No newline at end of file From 4f0279e17e6cb545f613ddba7f3b13b5d7579842 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Sep 2025 22:41:33 +0300 Subject: [PATCH 3/9] Improve Cursor rules --- .cursor/rules/general.mdc | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index d6bd4c8..5ba1ecc 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -9,9 +9,11 @@ When working with this project, follow these rules: - Do NOT "test the implementation" when you are done. The only exception is when you need to typecheck or build the app, in that case: - - To typecheck, run "npm run frontend:typecheck". - - To build, run "npm run frontend:build". + - To typecheck, run `npm run frontend:typecheck`. + - To build, run `npm run frontend:build`. Do NOT execute other commands like "cd". - Do NOT "cd" to the project directory. -- If possible, try to update files in a single edit. \ No newline at end of file +- If possible, try to update files in a single edit. +- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async, + make it async. The import is `/frontend/src/utils/utils`. \ No newline at end of file From 35b573945ec8afbd16a64d5ab2ca14f01c921d35 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Sep 2025 23:22:24 +0300 Subject: [PATCH 4/9] Fix the structure --- .../src/ui/components/chat/ChatMessages.tsx | 6 ++++-- .../components/chat/MessagePanelRenderer.tsx | 20 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index 45aca19..1ba1d4d 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -6,15 +6,16 @@ import type { UserProfile } from "../../../core/types"; import { UserProfileDialog } from "./UserProfileDialog"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { fetchUserProfile } from "../../api/profileApi"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { delay } from "../../../utils/utils"; import { request } from "../../../websocket"; interface ChatMessagesProps { messages?: MessageType[]; + children?: ReactNode; } -export function ChatMessages({ messages: propMessages }: ChatMessagesProps) { +export function ChatMessages({ messages: propMessages, children }: ChatMessagesProps) { const { messages: hookMessages } = useChat(); const { user } = useAppState(); @@ -137,6 +138,7 @@ export function ChatMessages({ messages: propMessages }: ChatMessagesProps) { isLoadingProfile={isLoadingProfile} /> ))} + {children}
Свернуть чат
- -
- {panelState.isLoading ? ( + + {panelState.isLoading ? ( +
Loading messages...
- ) : ( - <> - -
- - )} -
+
+ ): ( + +
+ + )}
From a6594d596009206eec280cf34269105973bc7295 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Sep 2025 23:33:21 +0300 Subject: [PATCH 5/9] Hide the profile picture and username in DMs --- frontend/src/ui/components/chat/ChatMessages.tsx | 5 +++-- frontend/src/ui/components/chat/Message.tsx | 7 ++++--- frontend/src/ui/components/chat/MessagePanelRenderer.tsx | 2 +- frontend/src/ui/panels/DMPanel.ts | 4 ++++ frontend/src/ui/panels/MessagePanel.ts | 1 + frontend/src/ui/panels/PublicChatPanel.ts | 4 ++++ 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index 1ba1d4d..c585991 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -12,10 +12,11 @@ import { request } from "../../../websocket"; interface ChatMessagesProps { messages?: MessageType[]; + isDm?: boolean; children?: ReactNode; } -export function ChatMessages({ messages: propMessages, children }: ChatMessagesProps) { +export function ChatMessages({ messages: propMessages, children, isDm = false }: ChatMessagesProps) { const { messages: hookMessages } = useChat(); const { user } = useAppState(); @@ -136,7 +137,7 @@ export function ChatMessages({ messages: propMessages, children }: ChatMessagesP onProfileClick={handleProfileClick} onContextMenu={handleContextMenu} isLoadingProfile={isLoadingProfile} - /> + isDm={isDm} /> ))} {children}
diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index 0536724..e3705cf 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -8,9 +8,10 @@ interface MessageProps { onProfileClick: (username: string) => void; onContextMenu: (e: React.MouseEvent, message: MessageType) => void; isLoadingProfile?: boolean; + isDm?: boolean; } -export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false }: MessageProps) { +export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) { const handleContextMenu = (e: React.MouseEvent) => { console.log("Message context menu event triggered for message:", message.id); e.preventDefault(); @@ -26,7 +27,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo >
{/* Add profile picture for received messages */} - {!isAuthor && ( + {!isAuthor && !isDm && (
)} - {!isAuthor && ( + {!isAuthor && !isDm && (
!isLoadingProfile && onProfileClick(message.username)} diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index bf052de..2b276f0 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -118,7 +118,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
): ( - +
)} diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index af52a70..c9cb255 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -28,6 +28,10 @@ export class DMPanel extends MessagePanel { super("dm", user, callbacks, onStateChange); } + isDm(): boolean { + return true; + } + async activate(): Promise { if (this.dmData && !this.messagesLoaded) { await this.loadMessages(); diff --git a/frontend/src/ui/panels/MessagePanel.ts b/frontend/src/ui/panels/MessagePanel.ts index 0fd3a75..78320ba 100644 --- a/frontend/src/ui/panels/MessagePanel.ts +++ b/frontend/src/ui/panels/MessagePanel.ts @@ -49,6 +49,7 @@ export abstract class MessagePanel { abstract deactivate(): void; abstract loadMessages(): Promise; abstract sendMessage(content: string): Promise; + abstract isDm(): boolean; // Common methods protected updateState(updates: Partial): void { diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index 09532de..08e5eca 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -23,6 +23,10 @@ export class PublicChatPanel extends MessagePanel { }); } + isDm(): boolean { + return false; + } + async activate(): Promise { if (!this.messagesLoaded) { await this.loadMessages(); From ba267791e4ddd2088a9b42ed4c9cbe880e9acad1 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Sep 2025 23:35:10 +0300 Subject: [PATCH 6/9] Remove the useless button --- frontend/src/ui/components/chat/MessagePanelRenderer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 2b276f0..4ffb83d 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -101,7 +101,6 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen {panelState.isTyping && " • Typing..."}

- Свернуть чат
From d3d8a53de390ff849d1efd218a4e095253eb1c62 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Sep 2025 23:37:55 +0300 Subject: [PATCH 7/9] Clean up the logs --- frontend/src/ui/components/chat/ChatMessages.tsx | 1 - frontend/src/ui/components/chat/Message.tsx | 1 - frontend/src/ui/components/chat/MessageContextMenu.tsx | 1 - frontend/src/ui/components/core/Dialog.tsx | 1 - 4 files changed, 4 deletions(-) diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index c585991..ddf2a3e 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -52,7 +52,6 @@ export function ChatMessages({ messages: propMessages, children, isDm = false }: const handleContextMenu = (e: React.MouseEvent, message: MessageType) => { e.preventDefault(); - console.log("Context menu triggered for message:", message.id, "at position:", e.clientX, e.clientY); setContextMenu({ isOpen: true, message, diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index e3705cf..160c967 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -13,7 +13,6 @@ interface MessageProps { export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) { const handleContextMenu = (e: React.MouseEvent) => { - console.log("Message context menu event triggered for message:", message.id); e.preventDefault(); e.stopPropagation(); onContextMenu(e, message); diff --git a/frontend/src/ui/components/chat/MessageContextMenu.tsx b/frontend/src/ui/components/chat/MessageContextMenu.tsx index e8c1883..781470c 100644 --- a/frontend/src/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/ui/components/chat/MessageContextMenu.tsx @@ -114,7 +114,6 @@ export function MessageContextMenu({ }, [isOpen, isClosing, editDialogOpen, replyDialogOpen]); const handleAction = (action: string) => { - console.log("Context menu action triggered:", action); switch (action) { case "reply": setReplyDialogOpen(true); diff --git a/frontend/src/ui/components/core/Dialog.tsx b/frontend/src/ui/components/core/Dialog.tsx index 6735ae5..70f8016 100644 --- a/frontend/src/ui/components/core/Dialog.tsx +++ b/frontend/src/ui/components/core/Dialog.tsx @@ -20,7 +20,6 @@ export function MaterialDialog(props: FullDialogProps) { mutations.forEach((mutation) => { if (mutation.type === "attributes" && mutation.attributeName === "open") { const isOpen = dialog.hasAttribute("open"); - console.log("isOpen:", isOpen); if (isOpen !== props.open) { props.onOpenChange(isOpen); } From 7a9feda99161e8ae75543d408d5193ecfe6d35a6 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 6 Sep 2025 11:47:49 +0300 Subject: [PATCH 8/9] Fix real-time messaging --- .../components/chat/MessagePanelRenderer.tsx | 17 ++++++++- frontend/src/ui/hooks/useChat.ts | 36 ++----------------- frontend/src/ui/panels/MessagePanel.ts | 5 ++- frontend/src/ui/state.ts | 5 ++- frontend/src/websocket.ts | 33 ++++++++++++++++- 5 files changed, 58 insertions(+), 38 deletions(-) diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 4ffb83d..4c53a9b 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel"; import { ChatMessages } from "./ChatMessages"; import { ChatInputWrapper } from "./ChatInputWrapper"; +import { setGlobalMessageHandler } from "../../../websocket"; import defaultAvatar from "../../../resources/images/default-avatar.png"; interface MessagePanelRendererProps { @@ -26,10 +27,24 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen }; // Store the handler for cleanup - (panel as any).onStateChange = handleStateChange; + 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 && (panel as any).onStateChange) { + (panel as any).onStateChange = null; + } + }; }, [panel]); // Handle chat switching animation diff --git a/frontend/src/ui/hooks/useChat.ts b/frontend/src/ui/hooks/useChat.ts index 78370ff..e800fde 100644 --- a/frontend/src/ui/hooks/useChat.ts +++ b/frontend/src/ui/hooks/useChat.ts @@ -70,40 +70,8 @@ export function useChat() { } }, [user.authToken]); - // Handle WebSocket messages - useEffect(() => { - const handleWebSocketMessage = (event: MessageEvent) => { - try { - const response: WebSocketMessage = JSON.parse(event.data); - - switch (response.type) { - case 'messageEdited': - if (response.data) { - updateMessage(response.data.id, response.data); - } - break; - case 'messageDeleted': - if (response.data && response.data.message_id) { - removeMessage(response.data.message_id); - } - break; - case 'newMessage': - if (response.data) { - addMessage(response.data); - } - break; - } - } catch (error) { - console.error("Error parsing WebSocket message:", error); - } - }; - - websocket.addEventListener("message", handleWebSocketMessage); - - return () => { - websocket.removeEventListener("message", handleWebSocketMessage); - }; - }, [addMessage, updateMessage, removeMessage, user.currentUser]); + // 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(() => { diff --git a/frontend/src/ui/panels/MessagePanel.ts b/frontend/src/ui/panels/MessagePanel.ts index 78320ba..f5af090 100644 --- a/frontend/src/ui/panels/MessagePanel.ts +++ b/frontend/src/ui/panels/MessagePanel.ts @@ -22,7 +22,7 @@ export interface MessagePanelCallbacks { export abstract class MessagePanel { protected state: MessagePanelState; protected callbacks: MessagePanelCallbacks; - protected onStateChange: (state: MessagePanelState) => void; + public onStateChange: (state: MessagePanelState) => void; protected currentUser: UserState; constructor( @@ -50,6 +50,9 @@ export abstract class MessagePanel { abstract loadMessages(): Promise; abstract sendMessage(content: string): Promise; abstract isDm(): boolean; + + // Optional WebSocket message handler (can be overridden by subclasses) + handleWebSocketMessage?: (response: any) => void; // Common methods protected updateState(updates: Partial): void { diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index 1ef2486..696115e 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { Message, User, WebSocketMessage } from "../core/types"; -import { request } from "../websocket"; +import { request, reconnectWebSocket } from "../websocket"; import { MessagePanel } from "./panels/MessagePanel"; import { PublicChatPanel } from "./panels/PublicChatPanel"; import { DMPanel, type DMPanelData } from "./panels/DMPanel"; @@ -151,6 +151,9 @@ export const useAppState = create((set, get) => ({ } })); + // Reconnect WebSocket with new auth token + reconnectWebSocket(); + try { const payload: WebSocketMessage = { type: "ping", diff --git a/frontend/src/websocket.ts b/frontend/src/websocket.ts index 54dcf3b..607bbe4 100644 --- a/frontend/src/websocket.ts +++ b/frontend/src/websocket.ts @@ -29,6 +29,20 @@ function create(): WebSocket { */ export let websocket: WebSocket = create(); +/** + * Global WebSocket message handler reference + * This will be set by the active panel to handle incoming messages + */ +let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null; + +/** + * Set the global WebSocket message handler + * @param handler - Function to handle WebSocket messages + */ +export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void { + globalMessageHandler = handler; +} + export function request(payload: WebSocketMessage): Promise { return new Promise((resolve, reject) => { let listener: ((e: MessageEvent) => void) | null = null; @@ -65,11 +79,28 @@ async function onError() { websocket.addEventListener("error", onError); } +/** + * Recreate WebSocket connection (useful when user logs in) + */ +export function reconnectWebSocket(): void { + websocket = create(); + websocket.addEventListener("error", onError); +} + // -------------- // Initialization // -------------- websocket.addEventListener("message", (e) => { - // handleWebSocketMessage(JSON.parse(e.data)); + try { + const response: WebSocketMessage = JSON.parse(e.data); + + // Route message to global handler if set + if (globalMessageHandler) { + globalMessageHandler(response); + } + } catch (error) { + console.error("Error parsing WebSocket message:", error); + } }); websocket.addEventListener("error", onError); \ No newline at end of file From 7429289dc68f78015896160e5fd8a89b64a860a7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 6 Sep 2025 12:05:27 +0300 Subject: [PATCH 9/9] Fix DMs --- backend/routes/messaging.py | 10 ++++++--- frontend/src/ui/panels/DMPanel.ts | 2 +- frontend/src/ui/state.ts | 5 +---- frontend/src/websocket.ts | 34 ++++++++++++++++--------------- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 79a3ef3..2868f59 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -304,7 +304,8 @@ class MessaggingSocketManager: db.add(env) db.commit() db.refresh(env) - await self.send_to_user(env.recipient_id, { + + payload = { "type": "dmNew", "data": { "id": env.id, @@ -317,8 +318,11 @@ class MessaggingSocketManager: "wrappedMk": env.wrapped_mk_b64, "timestamp": env.timestamp.isoformat(), } - }) - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) + } + + await self.send_to_user(env.recipient_id, payload); + await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); + await self.send_to_user(env.sender_id, payload); except HTTPException as e: await self.send_error(websocket, type, e) elif type == "editMessage": diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index c9cb255..434da14 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -130,7 +130,7 @@ export class DMPanel extends MessagePanel { this.addMessage({ id: envelope.id, content: plaintext, - username: isAuthor ? "You" : this.dmData.username, + username: isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData.username, timestamp: envelope.timestamp, is_read: false, is_edited: false diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index 696115e..1ef2486 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { Message, User, WebSocketMessage } from "../core/types"; -import { request, reconnectWebSocket } from "../websocket"; +import { request } from "../websocket"; import { MessagePanel } from "./panels/MessagePanel"; import { PublicChatPanel } from "./panels/PublicChatPanel"; import { DMPanel, type DMPanelData } from "./panels/DMPanel"; @@ -151,9 +151,6 @@ export const useAppState = create((set, get) => ({ } })); - // Reconnect WebSocket with new auth token - reconnectWebSocket(); - try { const payload: WebSocketMessage = { type: "ping", diff --git a/frontend/src/websocket.ts b/frontend/src/websocket.ts index 607bbe4..a6f0900 100644 --- a/frontend/src/websocket.ts +++ b/frontend/src/websocket.ts @@ -44,16 +44,26 @@ export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) = } export function request(payload: WebSocketMessage): Promise { + console.log("WebSocket request:", payload); return new Promise((resolve, reject) => { - let listener: ((e: MessageEvent) => void) | null = null; - listener = (e) => { - resolve(JSON.parse(e.data)); - websocket.removeEventListener("message", listener!); - } - websocket.addEventListener("message", listener); - websocket.send(JSON.stringify(payload)) + function requestInner() { + let listener: ((e: MessageEvent) => void) | null = null; + listener = (e) => { + resolve(JSON.parse(e.data)); + websocket.removeEventListener("message", listener!); + } + websocket.addEventListener("message", listener); + websocket.send(JSON.stringify(payload)) - setTimeout(() => reject("Request timed out"), 10000); + setTimeout(() => reject("Request timed out"), 10000); + } + + if (websocket.readyState == 0) { + websocket.addEventListener("open", requestInner); + setTimeout(() => reject("Request timed out"), 10000); + } else { + requestInner(); + } }) } @@ -79,14 +89,6 @@ async function onError() { websocket.addEventListener("error", onError); } -/** - * Recreate WebSocket connection (useful when user logs in) - */ -export function reconnectWebSocket(): void { - websocket = create(); - websocket.addEventListener("error", onError); -} - // -------------- // Initialization // --------------