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