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;