diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 486002e..2903285 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -2,7 +2,8 @@ import { ElectronTitleBar } from "./components/Electron"; import ChatScreen from "./screen/ChatScreen"; import LoginScreen from "./screen/LoginScreen"; import RegisterScreen from "./screen/RegisterScreen"; -import { useAppState } from "./state" +import { useAppState } from "./state"; +import { DialogProvider } from "./contexts/DialogContext"; export default function App() { const { currentPage } = useAppState(); @@ -25,11 +26,11 @@ export default function App() { } return ( - <> +
{page}
- +
) } \ No newline at end of file diff --git a/frontend/src/ui/components/chat/BottomAppBar.tsx b/frontend/src/ui/components/chat/BottomAppBar.tsx index ef810fa..c0c7278 100644 --- a/frontend/src/ui/components/chat/BottomAppBar.tsx +++ b/frontend/src/ui/components/chat/BottomAppBar.tsx @@ -1,7 +1,15 @@ +import { useDialog } from "../../contexts/DialogContext"; + export function BottomAppBar() { + const { openSettings } = useDialog(); + + const handleSettingsClick = () => { + openSettings(); + }; + return ( - +
diff --git a/frontend/src/ui/components/chat/ChatHeader.tsx b/frontend/src/ui/components/chat/ChatHeader.tsx index 9939ad9..25786a0 100644 --- a/frontend/src/ui/components/chat/ChatHeader.tsx +++ b/frontend/src/ui/components/chat/ChatHeader.tsx @@ -1,11 +1,18 @@ import { PRODUCT_NAME } from "../../../core/config"; +import { useDialog } from "../../contexts/DialogContext"; export function ChatHeader() { + const { openProfile } = useDialog(); + + const handleProfileClick = () => { + openProfile(); + }; + return (
{PRODUCT_NAME}
- +
diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 681ebad..228e8b2 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -1,9 +1,31 @@ +import { useState } from "react"; +import { useChat } from "../../hooks/useChat"; + export function ChatInputWrapper() { + const [message, setMessage] = useState(""); + const { sendMessage } = useChat(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (message.trim()) { + await sendMessage(message); + setMessage(""); + } + }; + return (
-
- + + setMessage(e.target.value)} + /> diff --git a/frontend/src/ui/components/chat/ChatMainHeader.tsx b/frontend/src/ui/components/chat/ChatMainHeader.tsx index 517199f..dae8c71 100644 --- a/frontend/src/ui/components/chat/ChatMainHeader.tsx +++ b/frontend/src/ui/components/chat/ChatMainHeader.tsx @@ -1,16 +1,27 @@ +import { useChat } from "../../hooks/useChat"; +import { useState } from "react"; + export function ChatMainHeader() { + const { currentChat } = useChat(); + const [isCollapsed, setIsCollapsed] = useState(false); + + const handleCollapse = () => { + setIsCollapsed(!isCollapsed); + // TODO: Implement chat collapse animation + }; + return (
Avatar
-

Общий чат

+

{currentChat}

Онлайн

- Свернуть чат + Свернуть чат
); diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index 0de636b..16e773a 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -1,6 +1,34 @@ +import { useChat } from "../../hooks/useChat"; +import { Message } from "./Message"; +import { useAppState } from "../../state"; +import type { Message as MessageType } from "../../../core/types"; + export function ChatMessages() { + const { messages } = useChat(); + const { user } = useAppState(); + + const handleProfileClick = (username: string) => { + // TODO: Show user profile dialog + console.log("Show profile for:", username); + }; + + const handleContextMenu = (e: React.MouseEvent, message: MessageType) => { + e.preventDefault(); + // TODO: Show context menu + console.log("Show context menu for message:", message.id); + }; + return (
+ {messages.map((message) => ( + + ))}
); } diff --git a/frontend/src/ui/components/chat/ChatTabs.tsx b/frontend/src/ui/components/chat/ChatTabs.tsx index 466328f..f7a9d1b 100644 --- a/frontend/src/ui/components/chat/ChatTabs.tsx +++ b/frontend/src/ui/components/chat/ChatTabs.tsx @@ -1,18 +1,46 @@ +import { useChat } from "../../hooks/useChat"; + export function ChatTabs() { + const { activeTab, setActiveTab, setCurrentChat } = useChat(); + + const handleChatClick = (chatName: string) => { + setCurrentChat(chatName); + }; + return (
- - Чаты - Каналы - Контакты - ЛС + setActiveTab(e.detail.value)}> + + Чаты + + + Каналы + + + Контакты + + + ЛС + - + handleChatClick("Общий чат")} + style={{ cursor: "pointer" }} + > - + handleChatClick("Общий чат 2")} + style={{ cursor: "pointer" }} + > diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx new file mode 100644 index 0000000..8e44817 --- /dev/null +++ b/frontend/src/ui/components/chat/Message.tsx @@ -0,0 +1,70 @@ +import { formatTime } from "../../../utils/utils"; +import type { Message as MessageType } from "../../../core/types"; +import defaultAvatar from "../../../resources/images/default-avatar.png"; + +interface MessageProps { + message: MessageType; + isAuthor: boolean; + onProfileClick: (username: string) => void; + onContextMenu: (e: React.MouseEvent, message: MessageType) => void; +} + +export function Message({ message, isAuthor, onProfileClick, onContextMenu }: MessageProps) { + return ( +
onContextMenu(e, message)} + > +
+ {/* Add profile picture for received messages */} + {!isAuthor && ( +
+ {message.username} onProfileClick(message.username)} + style={{ cursor: "pointer" }} + onError={(e) => { + const target = e.target as HTMLImageElement; + target.src = defaultAvatar; + }} + /> +
+ )} + + {!isAuthor && ( +
onProfileClick(message.username)} + style={{ cursor: "pointer" }}> {/* TODO extract to SCSS */} + {message.username} +
+ )} + + {/* Add reply preview if this is a reply */} + {message.reply_to && ( +
+
+ {message.reply_to.username} + {message.reply_to.content} +
+
+ )} + +
+ {message.content} +
+ +
+ {formatTime(message.timestamp)} + {message.is_edited ? " (edited)" : undefined} + + {isAuthor && message.is_read ? ( + + ) : undefined} +
+
+
+ ); +} diff --git a/frontend/src/ui/components/profile/ProfileDialog.tsx b/frontend/src/ui/components/profile/ProfileDialog.tsx index e73ae87..140956c 100644 --- a/frontend/src/ui/components/profile/ProfileDialog.tsx +++ b/frontend/src/ui/components/profile/ProfileDialog.tsx @@ -1,6 +1,22 @@ +import { useState } from "react"; +import { useAppState } from "../../state"; +import { useDialog } from "../../contexts/DialogContext"; + export function ProfileDialog() { + const [username, setUsername] = useState("user123"); + const [description, setDescription] = useState(""); + const { user } = useAppState(); + const { isProfileOpen, closeProfile } = useDialog(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // TODO: Implement profile update logic + console.log("Profile update:", { username, description }); + closeProfile(); + }; + return ( - +
@@ -8,21 +24,29 @@ export function ProfileDialog() {
- + setUsername(e.target.value)} + autocomplete="username"> +
- + setDescription(e.target.value)} placeholder="Расскажите о себе..." - autocomplete="none"> + autocomplete="none"> +
Сохранить изменения - Закрыть + Закрыть
diff --git a/frontend/src/ui/components/settings/SettingsDialog.tsx b/frontend/src/ui/components/settings/SettingsDialog.tsx index a83bc18..f856526 100644 --- a/frontend/src/ui/components/settings/SettingsDialog.tsx +++ b/frontend/src/ui/components/settings/SettingsDialog.tsx @@ -1,24 +1,91 @@ +import { useState } from "react"; +import { useDialog } from "../../contexts/DialogContext"; +import { PRODUCT_NAME } from "../../../core/config"; + export function SettingsDialog() { + const { isSettingsOpen, closeSettings } = useDialog(); + const [activePanel, setActivePanel] = useState("notifications-settings"); + + const handlePanelChange = (panelId: string) => { + setActivePanel(panelId); + }; + return ( - +
- + Настройки
- Уведомления - Внешний вид - Безопасность - Язык - Хранилище - Помощь - О приложении + handlePanelChange("notifications-settings")} + style={{ cursor: "pointer" }} + > + Уведомления + + handlePanelChange("appearance-settings")} + style={{ cursor: "pointer" }} + > + Внешний вид + + handlePanelChange("security-settings")} + style={{ cursor: "pointer" }} + > + Безопасность + + handlePanelChange("language-settings")} + style={{ cursor: "pointer" }} + > + Язык + + handlePanelChange("storage-settings")} + style={{ cursor: "pointer" }} + > + Хранилище + + handlePanelChange("help-settings")} + style={{ cursor: "pointer" }} + > + Помощь + + handlePanelChange("about-settings")} + style={{ cursor: "pointer" }} + > + О приложении +
-
+

Уведомления

Новые сообщения Звуковые уведомления @@ -26,7 +93,7 @@ export function SettingsDialog() { Email уведомления
-
+

Внешний вид

Тёмная @@ -40,14 +107,14 @@ export function SettingsDialog() {
-
+

Безопасность

Изменить пароль Двухфакторная аутентификация Автоматический выход
-
+

Язык

Русский @@ -56,24 +123,24 @@ export function SettingsDialog() {
-
+

Хранилище

Использовано: 2.5 ГБ из 10 ГБ

Очистить кэш
-
+

Помощь

Руководство пользователя Связаться с поддержкой FAQ
-
+

О приложении

Версия: 1.0.0

-

© 2025 Loading.... Все права защищены.

+

© 2025 {PRODUCT_NAME}. Все права защищены.

Политика конфиденциальности Условия использования
diff --git a/frontend/src/ui/contexts/DialogContext.tsx b/frontend/src/ui/contexts/DialogContext.tsx new file mode 100644 index 0000000..e7c61e9 --- /dev/null +++ b/frontend/src/ui/contexts/DialogContext.tsx @@ -0,0 +1,44 @@ +import { createContext, useContext, useState } from "react"; +import type { ReactNode } from "react"; + +interface DialogContextType { + isProfileOpen: boolean; + isSettingsOpen: boolean; + openProfile: () => void; + closeProfile: () => void; + openSettings: () => void; + closeSettings: () => void; +} + +const DialogContext = createContext(undefined); + +export function DialogProvider({ children }: { children: ReactNode }) { + const [isProfileOpen, setIsProfileOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + + const openProfile = () => setIsProfileOpen(true); + const closeProfile = () => setIsProfileOpen(false); + const openSettings = () => setIsSettingsOpen(true); + const closeSettings = () => setIsSettingsOpen(false); + + return ( + + {children} + + ); +} + +export function useDialog() { + const context = useContext(DialogContext); + if (context === undefined) { + throw new Error("useDialog must be used within a DialogProvider"); + } + return context; +} diff --git a/frontend/src/ui/hooks/useChat.ts b/frontend/src/ui/hooks/useChat.ts new file mode 100644 index 0000000..5cd9bb5 --- /dev/null +++ b/frontend/src/ui/hooks/useChat.ts @@ -0,0 +1,124 @@ +import { useEffect, useCallback } from "react"; +import { useAppState } from "../state"; +import { request, websocket } from "../../websocket"; +import { API_BASE_URL } from "../../core/config"; +import type { Message, WebSocketMessage, User } from "../../core/types"; +import { getAuthHeaders } from "../../auth/api"; + +export function useChat() { + const { + chat, + addMessage, + updateMessage, + removeMessage, + clearMessages, + setCurrentChat, + setActiveTab, + setDmUsers, + setActiveDm, + user + } = useAppState(); + + // Load messages for the current chat + const loadMessages = useCallback(async () => { + if (!user.authToken) return; + + try { + const response = await fetch(`${API_BASE_URL}/get_messages`, { + headers: getAuthHeaders() + }); + + if (response.ok) { + const data = await response.json(); + if (data.messages && data.messages.length > 0) { + // Clear existing messages and add new ones + clearMessages(); + data.messages.forEach((msg: Message) => { + addMessage(msg); + }); + } + } + } catch (error) { + console.error("Error loading messages:", error); + } + }, [user.authToken, addMessage, clearMessages]); + + // Send a message + const sendMessage = useCallback(async (content: string) => { + if (!user.authToken || !content.trim()) return; + + try { + const response = await request({ + data: { content: content.trim() }, + credentials: { + scheme: "Bearer", + credentials: user.authToken + }, + type: "sendMessage" + }); + + if (response.error) { + console.error("Error sending message:", response.error); + } + } catch (error) { + console.error("Error sending message:", error); + } + }, [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) { + const isAuthor = response.data.username === user.currentUser?.username; + addMessage(response.data); + } + break; + } + } catch (error) { + console.error("Error parsing WebSocket message:", error); + } + }; + + websocket.addEventListener("message", handleWebSocketMessage); + + return () => { + websocket.removeEventListener("message", handleWebSocketMessage); + }; + }, [addMessage, user.currentUser]); + + // Load messages when component mounts or chat changes + useEffect(() => { + loadMessages(); + }, [loadMessages]); + + return { + messages: chat.messages, + currentChat: chat.currentChat, + activeTab: chat.activeTab, + dmUsers: chat.dmUsers, + activeDm: chat.activeDm, + sendMessage, + updateMessage, + removeMessage, + clearMessages, + setCurrentChat, + setActiveTab, + setDmUsers, + setActiveDm + }; +} diff --git a/frontend/src/ui/screen/LoginScreen.tsx b/frontend/src/ui/screen/LoginScreen.tsx index 24a3cbd..452b796 100644 --- a/frontend/src/ui/screen/LoginScreen.tsx +++ b/frontend/src/ui/screen/LoginScreen.tsx @@ -2,7 +2,6 @@ import { useImmer } from "use-immer"; import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts"; import { AuthContainer, AuthHeader } from "../components/Auth"; import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types"; -import { setUser } from "../../auth/api"; import { ensureKeysOnLogin } from "../../auth/crypto"; import { API_BASE_URL } from "../../core/config"; // import { initializeProfile } from "../../userPanel/profile/profile"; @@ -13,6 +12,7 @@ import { useAppState } from "../state"; export default function LoginScreen() { const [alerts, updateAlerts] = useImmer([]); const setCurrentPage = useAppState(state => state.setCurrentPage); + const setUser = useAppState(state => state.setUser); function showAlert(type: AlertType, message: string) { updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); diff --git a/frontend/src/ui/state.ts b/frontend/src/ui/state.ts index 022678a..4e517a7 100644 --- a/frontend/src/ui/state.ts +++ b/frontend/src/ui/state.ts @@ -1,13 +1,121 @@ import { create } from "zustand"; +import type { Message, User, UserProfile } from "../core/types"; type Page = "login" | "register" | "chat" +interface ChatState { + messages: Message[]; + currentChat: string; + activeTab: "chats" | "channels" | "contacts" | "dms"; + dmUsers: User[]; + activeDm: { userId: number; username: string; publicKey: string | null } | null; +} + +interface UserState { + currentUser: User | null; + authToken: string | null; +} + interface AppState { currentPage: Page; setCurrentPage: (page: Page) => void; + + // Chat state + chat: ChatState; + addMessage: (message: Message) => void; + updateMessage: (messageId: number, updatedMessage: Partial) => void; + removeMessage: (messageId: number) => void; + setCurrentChat: (chat: string) => void; + setActiveTab: (tab: ChatState["activeTab"]) => void; + setDmUsers: (users: User[]) => void; + setActiveDm: (dm: ChatState["activeDm"]) => void; + clearMessages: () => void; + + // User state + user: UserState; + setUser: (token: string, user: User) => void; + logout: () => void; } -export const useAppState = create((set) => ({ +export const useAppState = create((set, get) => ({ currentPage: "login", // default page - setCurrentPage: (page: Page) => set({ currentPage: page }) + setCurrentPage: (page: Page) => set({ currentPage: page }), + + // Chat state + chat: { + messages: [], + currentChat: "Общий чат", + activeTab: "chats", + dmUsers: [], + activeDm: null + }, + addMessage: (message: Message) => set((state) => ({ + chat: { + ...state.chat, + messages: [...state.chat.messages, message] + } + })), + updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ + chat: { + ...state.chat, + messages: state.chat.messages.map(msg => + msg.id === messageId ? { ...msg, ...updatedMessage } : msg + ) + } + })), + removeMessage: (messageId: number) => set((state) => ({ + chat: { + ...state.chat, + messages: state.chat.messages.filter(msg => msg.id !== messageId) + } + })), + clearMessages: () => set((state) => ({ + chat: { + ...state.chat, + messages: [] + } + })), + setCurrentChat: (chat: string) => set((state) => ({ + chat: { + ...state.chat, + currentChat: chat + } + })), + setActiveTab: (tab: ChatState["activeTab"]) => set((state) => ({ + chat: { + ...state.chat, + activeTab: tab + } + })), + setDmUsers: (users: User[]) => set((state) => ({ + chat: { + ...state.chat, + dmUsers: users + } + })), + setActiveDm: (dm: ChatState["activeDm"]) => set((state) => ({ + chat: { + ...state.chat, + activeDm: dm + } + })), + + // User state + user: { + currentUser: null, + authToken: null + }, + setUser: (token: string, user: User) => set((state) => ({ + user: { + currentUser: user, + authToken: token + } + })), + logout: () => set((state) => ({ + user: { + currentUser: null, + authToken: null + }, + currentPage: "login" + })) })); \ No newline at end of file