From dc4536cabc33340de3b130012f3e012bd7cce2d8 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 23:21:30 +0300 Subject: [PATCH] Refactor state --- frontend/src/App.tsx | 8 +- frontend/src/core/components/StatusBadge.tsx | 4 +- frontend/src/core/components/VerifyButton.tsx | 4 +- frontend/src/core/onlineStatusManager.ts | 4 +- frontend/src/core/typingManager.ts | 10 +- frontend/src/core/websocket.ts | 6 +- frontend/src/pages/ProtectedRoute.tsx | 4 +- frontend/src/pages/auth/LoginForm.tsx | 6 +- frontend/src/pages/auth/RegisterForm.tsx | 4 +- frontend/src/pages/chat/hooks/useCall.ts | 59 +- frontend/src/pages/chat/hooks/useDM.ts | 8 +- frontend/src/pages/chat/hooks/useProfile.ts | 4 +- frontend/src/pages/chat/state.ts | 728 ------------------ frontend/src/pages/chat/ui/ChatPage.tsx | 8 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 16 +- .../src/pages/chat/ui/left/ChatHeader.tsx | 6 +- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 4 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 12 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 12 +- .../chat/ui/left/settings/AccountPanel.tsx | 4 +- .../ui/left/settings/ChangePasswordDialog.tsx | 4 +- .../chat/ui/left/settings/DevicesPanel.tsx | 4 +- .../ui/left/settings/NotificationsPanel.tsx | 4 +- .../pages/chat/ui/right/ChatMainHeader.tsx | 4 +- .../src/pages/chat/ui/right/ChatMessages.tsx | 4 +- frontend/src/pages/chat/ui/right/Message.tsx | 8 +- .../chat/ui/right/MessageContextMenu.tsx | 4 +- .../chat/ui/right/MessagePanelRenderer.tsx | 37 +- .../pages/chat/ui/right/OnlineIndicator.tsx | 6 +- .../src/pages/chat/ui/right/OnlineStatus.tsx | 8 +- .../src/pages/chat/ui/right/RightPanel.tsx | 6 +- .../pages/chat/ui/right/calls/CallWindow.tsx | 9 +- .../chat/ui/right/calls/MinimizedCallBar.tsx | 7 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 2 +- .../chat/ui/right/panels/MessagePanel.ts | 2 +- .../chat/ui/right/panels/PublicChatPanel.ts | 2 +- frontend/src/pages/home/HomePage.tsx | 4 +- frontend/src/state/call.ts | 123 +++ frontend/src/state/chat.ts | 150 ++++ frontend/src/state/presence.ts | 41 + frontend/src/state/profile.ts | 14 + frontend/src/state/types.ts | 73 ++ frontend/src/state/user.ts | 159 ++++ 43 files changed, 720 insertions(+), 866 deletions(-) delete mode 100644 frontend/src/pages/chat/state.ts create mode 100644 frontend/src/state/call.ts create mode 100644 frontend/src/state/chat.ts create mode 100644 frontend/src/state/presence.ts create mode 100644 frontend/src/state/profile.ts create mode 100644 frontend/src/state/types.ts create mode 100644 frontend/src/state/user.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cb4602..73e1724 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; import { ElectronTitleBar } from "./Electron"; -import { useAppState } from "./pages/chat/state"; +import { useUserStore } from "./state/user"; import { lazy, useEffect, useRef, useState } from "react"; import { parseProfileLink } from "./core/profileLinks"; import NotFoundPage from "./pages/not-found/NotFoundPage"; @@ -117,14 +117,14 @@ function AnimatedRoutes() { } export default function App() { - const { restoreUserFromStorage, user } = useAppState(); + const { restoreFromStorage, user } = useUserStore(); const [authReady, setAuthReady] = useState(false); useEffect(() => { - restoreUserFromStorage().finally(() => { + restoreFromStorage().finally(() => { setAuthReady(true); }); - }, [restoreUserFromStorage]); + }, [restoreFromStorage]); return authReady && ( diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index b8755d2..6268a45 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { checkUserSimilarity } from "@/core/api/account/profile"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialIcon } from "@/utils/material"; interface StatusBadgeProps { @@ -11,7 +11,7 @@ interface StatusBadgeProps { export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) { const [isSimilarToVerified, setIsSimilarToVerified] = useState(false); - const { user } = useAppState(); + const { user } = useUserStore(); const className = `status-badge ${size}`; diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 4ddfa54..111a6ac 100644 --- a/frontend/src/core/components/VerifyButton.tsx +++ b/frontend/src/core/components/VerifyButton.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { verifyUser } from "@/core/api/account/profile"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; interface VerifyButtonProps { @@ -11,7 +11,7 @@ interface VerifyButtonProps { export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) { const [isVerifying, setIsVerifying] = useState(false); - const { user } = useAppState(); + const { user } = useUserStore(); // Only show for owner if (user.currentUser?.id !== 1) { diff --git a/frontend/src/core/onlineStatusManager.ts b/frontend/src/core/onlineStatusManager.ts index 6f2525a..fe05253 100644 --- a/frontend/src/core/onlineStatusManager.ts +++ b/frontend/src/core/onlineStatusManager.ts @@ -11,7 +11,7 @@ import type { SubscribeStatusWebSocketMessage, UnsubscribeStatusWebSocketMessage } from "./types"; -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; export interface UserStatus { online: boolean; @@ -96,7 +96,7 @@ export class OnlineStatusManager { this.statusCache.set(userId, { online, lastSeen }); // Update the global state - const { updateOnlineStatus } = useAppState.getState(); + const { updateOnlineStatus } = usePresenceStore.getState(); updateOnlineStatus(userId, online, lastSeen); } diff --git a/frontend/src/core/typingManager.ts b/frontend/src/core/typingManager.ts index da5b119..740c800 100644 --- a/frontend/src/core/typingManager.ts +++ b/frontend/src/core/typingManager.ts @@ -16,7 +16,7 @@ import type { DmTypingRequest, StopDmTypingRequest } from "./types"; -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; /** * Manages typing indicators for public chat and DMs @@ -133,7 +133,7 @@ export class TypingManager { * Handle incoming typing indicator from WebSocket */ handleTyping(message: TypingWebSocketMessage): void { - const { addTypingUser } = useAppState.getState(); + const { addTypingUser } = usePresenceStore.getState(); addTypingUser(message.data.userId, message.data.username); } @@ -141,7 +141,7 @@ export class TypingManager { * Handle incoming stop typing indicator from WebSocket */ handleStopTyping(message: StopTypingWebSocketMessage): void { - const { removeTypingUser } = useAppState.getState(); + const { removeTypingUser } = usePresenceStore.getState(); removeTypingUser(message.data.userId); } @@ -149,7 +149,7 @@ export class TypingManager { * Handle incoming DM typing indicator from WebSocket */ handleDmTyping(message: DmTypingWebSocketMessage): void { - const { setDmTypingUser } = useAppState.getState(); + const { setDmTypingUser } = usePresenceStore.getState(); setDmTypingUser(message.data.userId, true); } @@ -157,7 +157,7 @@ export class TypingManager { * Handle incoming stop DM typing indicator from WebSocket */ handleStopDmTyping(message: StopDmTypingWebSocketMessage): void { - const { setDmTypingUser } = useAppState.getState(); + const { setDmTypingUser } = usePresenceStore.getState(); setDmTypingUser(message.data.userId, false); } diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index d4400c1..6eea282 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -11,7 +11,7 @@ import { delay } from "@/utils/utils"; import { CallSignalingHandler } from "./calls/signaling"; import { onlineStatusManager } from "./onlineStatusManager"; import { typingManager } from "./typingManager"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; /** * Creates a new WebSocket connection to the chat server @@ -170,14 +170,14 @@ function setupEventHandlers(): void { typingManager.handleStopDmTyping(response as any); } else if (response.type === "suspended") { // Handle account suspension - const { setSuspended } = useAppState.getState(); + const { setSuspended } = useUserStore.getState(); const reason = response.data?.reason || "No reason provided"; setSuspended(reason); // Close WebSocket connection websocket.close(); } else if (response.type === "account_deleted") { // Handle account deletion - silent logout - const { logout } = useAppState.getState(); + const { logout } = useUserStore.getState(); logout(); // Close WebSocket connection websocket.close(); diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx index 9f1569f..c4c51cc 100644 --- a/frontend/src/pages/ProtectedRoute.tsx +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { useAppState } from "./chat/state"; +import { useUserStore } from "@/state/user"; import { Navigate } from "react-router-dom"; interface ProtectedRouteProps { @@ -7,7 +7,7 @@ interface ProtectedRouteProps { } export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { user } = useAppState(); + const { user } = useUserStore(); return !user.authToken ? : children; } diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 4d3a930..2f74010 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { motion, type Transition, type Variants } from "motion/react"; import { useImmer } from "use-immer"; import type { LoginRequest } from "@/core/types"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; @@ -53,7 +53,7 @@ interface LoginFormProps { export function LoginForm({ onSwitchMode }: LoginFormProps) { const [isLoading, setIsLoading] = useState(false); const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); + const setUser = useUserStore(state => state.setUser); const navigate = useNavigate(); function showAlert(type: AlertType, message: string) { @@ -119,7 +119,7 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { } } catch (error: any) { if (error.message && error.message.includes("suspension")) { - const setSuspended = useAppState.getState().setSuspended; + const setSuspended = useUserStore.getState().setSuspended; setSuspended(error.message || "No reason provided"); return; } diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index 9ef19b5..29961ed 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { motion, type Transition, type Variants } from "motion/react"; import { useImmer } from "use-immer"; import type { RegisterRequest } from "@/core/types"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialButton, MaterialIconButton } from "@/utils/material"; import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; @@ -51,7 +51,7 @@ interface RegisterFormProps { export function RegisterForm({ onSwitchMode }: RegisterFormProps) { const [isLoading, setIsLoading] = useState(false); const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); + const setUser = useUserStore(state => state.setUser); const navigate = useNavigate(); function showAlert(type: AlertType, message: string) { diff --git a/frontend/src/pages/chat/hooks/useCall.ts b/frontend/src/pages/chat/hooks/useCall.ts index 71e14c8..6372c31 100644 --- a/frontend/src/pages/chat/hooks/useCall.ts +++ b/frontend/src/pages/chat/hooks/useCall.ts @@ -1,4 +1,5 @@ -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; +import { useUserStore } from "@/state/user"; import * as WebRTC from "@/core/calls/webrtc"; import { CallSignalingHandler } from "@/core/calls/signaling"; import { setCallSignalingHandler } from "@/core/websocket"; @@ -15,7 +16,7 @@ let globalRemoteScreenShareRef = createRef(); export default function useCall() { const { - chat, + call, startCall, endCall, setCallStatus, @@ -26,8 +27,9 @@ export default function useCall() { setCallSessionKeyHash, setRemoteVideoEnabled, setRemoteScreenSharing, - user - } = useAppState(); + receiveCall + } = useCallStore(); + const { user } = useUserStore(); const remoteAudioRef = globalRemoteAudioRef; const localVideoRef = globalLocalVideoRef; @@ -40,8 +42,7 @@ export default function useCall() { const signalingHandler = new CallSignalingHandler(() => ({ receiveCall: (userId: number, username: string) => { // Use the receiveCall function from state - const state = useAppState.getState(); - state.receiveCall(userId, username); + receiveCall(userId, username); }, endCall, setCallSessionKeyHash, @@ -52,8 +53,8 @@ export default function useCall() { // Set up call state change handler WebRTC.callbacks.onCallStateChange = (userId: number, state: string) => { - const call = chat.call; - if (call.remoteUserId === userId) { + const currentCall = call; + if (currentCall.remoteUserId === userId) { switch (state) { case "connecting": setCallStatus("connecting"); @@ -183,15 +184,15 @@ export default function useCall() { WebRTC.cleanup(); setCallSignalingHandler(null); }; - }, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall, setRemoteVideoEnabled, setRemoteScreenSharing]); + }, [user.authToken, call.remoteUserId, setCallStatus, endCall, startCall, setRemoteVideoEnabled, setRemoteScreenSharing]); // Watch for session key hash changes and generate emojis useEffect(() => { - if (chat.call.sessionKeyHash && chat.call.encryptionEmojis.length === 0) { - const emojis = generateCallEmojis(chat.call.sessionKeyHash); - setCallEncryption(chat.call.sessionKeyHash, emojis); + if (call.sessionKeyHash && call.encryptionEmojis.length === 0) { + const emojis = generateCallEmojis(call.sessionKeyHash); + setCallEncryption(call.sessionKeyHash, emojis); } - }, [chat.call.sessionKeyHash, chat.call.encryptionEmojis.length, setCallEncryption]); + }, [call.sessionKeyHash, call.encryptionEmojis.length, setCallEncryption]); async function requestAudioPermissions(): Promise { try { @@ -249,12 +250,12 @@ export default function useCall() { } async function acceptCall() { - if (!chat.call.remoteUserId) { + if (!call.remoteUserId) { return; } setCallStatus("connecting"); - const success = await WebRTC.acceptCall(chat.call.remoteUserId); + const success = await WebRTC.acceptCall(call.remoteUserId); if (!success) { endCall(); @@ -262,46 +263,46 @@ export default function useCall() { } async function rejectCall() { - if (!chat.call.remoteUserId) { + if (!call.remoteUserId) { return; } - await WebRTC.rejectCall(chat.call.remoteUserId); + await WebRTC.rejectCall(call.remoteUserId); endCall(); } async function handleEndCall() { - if (chat.call.remoteUserId) { - await WebRTC.endCall(chat.call.remoteUserId); + if (call.remoteUserId) { + await WebRTC.endCall(call.remoteUserId); } endCall(); } function handleToggleMute() { - if (chat.call.remoteUserId) { - const isMuted = WebRTC.toggleMute(chat.call.remoteUserId); + if (call.remoteUserId) { + const isMuted = WebRTC.toggleMute(call.remoteUserId); // Update mute state in store - if (isMuted !== chat.call.isMuted) { + if (isMuted !== call.isMuted) { toggleMute(); } } } async function handleToggleVideo() { - if (chat.call.remoteUserId) { - const isEnabled = await WebRTC.toggleVideo(chat.call.remoteUserId); + if (call.remoteUserId) { + const isEnabled = await WebRTC.toggleVideo(call.remoteUserId); // Update video state in store - if (isEnabled !== chat.call.isVideoEnabled) { + if (isEnabled !== call.isVideoEnabled) { toggleVideo(); } } } async function handleToggleScreenShare() { - if (chat.call.remoteUserId) { - const isEnabled = await WebRTC.toggleScreenShare(chat.call.remoteUserId); + if (call.remoteUserId) { + const isEnabled = await WebRTC.toggleScreenShare(call.remoteUserId); // Update screen share state in store - if (isEnabled !== chat.call.isSharingScreen) { + if (isEnabled !== call.isSharingScreen) { toggleScreenShare(); } } @@ -336,7 +337,7 @@ export default function useCall() { } return { - call: chat.call, + call: call, initiateCall, acceptCall, rejectCall, diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index e652b18..92fb749 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { fetchUserPublicKey, fetchDMHistory, @@ -44,7 +45,8 @@ export function formatDMMessageContent( } export function useDM() { - const { user, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState(); + const { user } = useUserStore(); + const { setDmUsers, setActiveDm, addMessage, clearMessages } = useChatStore(); const [dmUsers, setDmUsersState] = useState([]); const [isLoadingUsers, setIsLoadingUsers] = useState(false); const [isLoadingHistory, setIsLoadingHistory] = useState(false); @@ -295,7 +297,7 @@ export function useDM() { // If conversation no longer exists, remove the user from the list setDmUsersState(prev => prev.filter(u => u.id !== userId)); // Get current dmUsers and filter out the removed user - const currentDmUsers = useAppState.getState().chat.dmUsers; + const currentDmUsers = useChatStore.getState().dmUsers; setDmUsers(currentDmUsers.filter((u: User) => u.id !== userId)); } } catch (error) { diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts index aa3f57b..9a8abff 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,10 +1,10 @@ import { useState, useCallback, useEffect } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile"; import { showSuccess, showError } from "@/utils/notification"; export default function useProfile() { - const { user } = useAppState(); + const { user } = useUserStore(); const [profileData, setProfileData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isUpdating, setIsUpdating] = useState(false); diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts deleted file mode 100644 index 7aea4c1..0000000 --- a/frontend/src/pages/chat/state.ts +++ /dev/null @@ -1,728 +0,0 @@ -import { create } from "zustand"; -import type { Message, User } from "@/core/types"; -import { request } from "@/core/websocket"; -import { MessagePanel } from "./ui/right/panels/MessagePanel"; -import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel"; -import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel"; -import { restoreKeys } from "@/core/api/account"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/account"; -import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; -import { isElectron } from "@/core/electron/electron"; -import { onlineStatusManager } from "@/core/onlineStatusManager"; -import { typingManager } from "@/core/typingManager"; - -export type ChatTabs = "chats" | "channels" | "contacts"; - -export type CallStatus = "calling" | "connecting" | "active" | "ended"; - -export interface ProfileDialogData { - userId?: number; - username?: string; - display_name?: string; - profilePicture?: string; - bio?: string; - memberSince?: string; - online?: boolean; - isOwnProfile: boolean; - verified?: boolean; - suspended?: boolean; - suspension_reason?: string | null; - deleted?: boolean; -} - -interface ActiveDM { - userId: number; - username: string; - publicKey: string | null -} - -interface CallState { - isActive: boolean; - status: CallStatus; - startTime: number | null; - isMuted: boolean; - remoteUserId: number | null; - remoteUsername: string | null; - isInitiator: boolean; - isMinimized: boolean; - sessionKeyHash: string | null; - encryptionEmojis: string[]; - isVideoEnabled: boolean; - isRemoteVideoEnabled: boolean; - isSharingScreen: boolean; - isRemoteScreenSharing: boolean; -} - -interface ChatState { - messages: Message[]; - currentChat: string; - activeTab: ChatTabs; - dmUsers: User[]; - activeDm: ActiveDM | null; - isSwitching: boolean; - setIsSwitching: (value: boolean) => void; - activePanel: MessagePanel | null; - publicChatPanel: PublicChatPanel | null; - dmPanel: DMPanel | null; - pendingPanel?: MessagePanel | null; - call: CallState; - profileDialog: ProfileDialogData | null; - onlineStatuses: Map; - typingUsers: Map; // userId -> username - dmTypingUsers: Map; -} - -export interface UserState { - currentUser: User | null; - authToken: string | null; - isSuspended: boolean; - suspensionReason: string | null; -} - -interface AppState { - // 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; - setActivePanel: (panel: MessagePanel | null) => void; - setPendingPanel: (panel: MessagePanel | null) => void; - applyPendingPanel: () => void; - switchToPublicChat: (chatName: string) => Promise; - switchToDM: (dmData: DMPanelData) => Promise; - - // Call state - startCall: (userId: number, username: string) => void; - endCall: () => void; - setCallStatus: (status: CallStatus) => void; - toggleMute: () => void; - toggleCallMinimize: () => void; - receiveCall: (userId: number, username: string) => void; - setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void; - setCallSessionKeyHash: (sessionKeyHash: string) => void; - toggleVideo: () => void; - toggleScreenShare: () => void; - setRemoteVideoEnabled: (enabled: boolean) => void; - setRemoteScreenSharing: (enabled: boolean) => void; - toggleCallMinimized: () => void; - - // User state - user: UserState; - setUser: (token: string, user: User) => void; - logout: () => void; - restoreUserFromStorage: () => Promise; - setSuspended: (reason: string) => void; - - // Profile dialog state - setProfileDialog: (data: ProfileDialogData | null) => void; - closeProfileDialog: () => void; - - // Online status and typing state - updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void; - addTypingUser: (userId: number, username: string) => void; - removeTypingUser: (userId: number) => void; - setDmTypingUser: (userId: number, isTyping: boolean) => void; -} - -export const useAppState = create((set, get) => ({ - // Chat state - chat: { - messages: [], - currentChat: "Общий чат", - activeTab: "chats", - dmUsers: [], - activeDm: null, - isSwitching: false, - setIsSwitching: (value: boolean) => set((state) => ({ - chat: { - ...state.chat, - isSwitching: value - } - })), - activePanel: null, - publicChatPanel: null, - dmPanel: null, - pendingPanel: null, - profileDialog: null, - call: { - isActive: false, - status: "ended", - startTime: null, - isMuted: false, - remoteUserId: null, - remoteUsername: null, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - }, - onlineStatuses: new Map(), - typingUsers: new Map(), - dmTypingUsers: new Map() - }, - addMessage: (message: Message) => set((state) => { - // Check if message already exists to prevent duplicates - const messageExists = state.chat.messages.some(msg => msg.id === message.id); - if (messageExists) { - return state; // Return unchanged state if message already exists - } - - return { - 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, - isSuspended: false, - suspensionReason: null - }, - setUser: (token: string, user: User) => { - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: user.suspended || false, - suspensionReason: user.suspension_reason || null - } - })); - - // Initialize managers with auth token - onlineStatusManager.setAuthToken(token); - typingManager.setAuthToken(token); - - // Store credentials in localStorage - try { - localStorage.setItem('authToken', token); - localStorage.setItem('currentUser', JSON.stringify(user)); - } catch (error) { - console.error('Failed to store credentials in localStorage:', error); - } - - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} - }, - logout: () => { - // Clear localStorage - try { - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); - } catch (error) { - console.error('Failed to clear localStorage:', error); - } - - // Cleanup managers - onlineStatusManager.setAuthToken(null); - typingManager.setAuthToken(null); - onlineStatusManager.cleanup(); - typingManager.cleanup(); - - set(() => ({ - user: { - currentUser: null, - authToken: null, - isSuspended: false, - suspensionReason: null - } - })); - }, - restoreUserFromStorage: async () => { - try { - const token = localStorage.getItem('authToken'); - - if (token) { - // Fetch full user profile - const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token, true) - }); - if (fullResponse.ok) { - const user: User = await fullResponse.json(); - restoreKeys(); - - // Check if user is suspended - if (user.suspended) { - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: true, - suspensionReason: user.suspension_reason || null - } - })); - return; // Don't initialize managers or notifications for suspended users - } - - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: false, - suspensionReason: null - } - })); - - // Initialize managers with auth token - onlineStatusManager.setAuthToken(token); - typingManager.setAuthToken(token); - - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} - - // Initialize notifications after successful credential restoration - try { - if (isSupported()) { - const initialized = await initialize(); - if (initialized) { - await subscribe(token); - - // For Electron, start the notification receiver - if (isElectron) { - await startElectronReceiver(); - } - } - } - } catch (e) { - console.error("Notification setup failed (restored):", e); - } - } else { - throw new Error("Unable to authenticate"); - } - } - } catch (error) { - console.error('Failed to restore user from localStorage:', error); - // Clear invalid data - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); - } - }, - - // Panel management - setActivePanel: (panel: MessagePanel | null) => { - const state = get(); - // Deactivate the current panel before switching - if (state.chat.activePanel && state.chat.activePanel !== panel) { - state.chat.activePanel.deactivate(); - } - return set((state) => ({ - chat: { - ...state.chat, - activePanel: panel - } - })); - }, - // Stash a panel to be applied after switch-out animation ends - setPendingPanel: (panel: MessagePanel | null) => set((state) => ({ - chat: { - ...state.chat, - pendingPanel: panel - } - })), - // Apply pending panel atomically and update related fields - applyPendingPanel: () => { - const state = get(); - // Deactivate the current panel before switching - if (state.chat.activePanel) { - state.chat.activePanel.deactivate(); - } - return set((state) => ({ - chat: { - ...state.chat, - activePanel: state.chat.pendingPanel || state.chat.activePanel, - // when switching to public chat, keep reference if type matches - publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel) - ? (state.chat.pendingPanel as PublicChatPanel) - : state.chat.publicChatPanel, - dmPanel: (state.chat.pendingPanel instanceof DMPanel) - ? (state.chat.pendingPanel as DMPanel) - : state.chat.dmPanel, - // update currentChat from panel title if available - currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat, - pendingPanel: null - } - })); - }, - - switchToPublicChat: async (chatName: string) => { - const { user, chat } = get(); - - if (!user.authToken) return; - - // Start chat switching animation - chat.setIsSwitching(true); - - // Create or get public chat panel - let publicChatPanel = chat.publicChatPanel; - if (!publicChatPanel) { - publicChatPanel = new PublicChatPanel(chatName, user); - } else { - publicChatPanel.setChatName(chatName); - publicChatPanel.setAuthToken(user.authToken); - // Reset messages for the new chat - publicChatPanel.clearMessages(); - } - - // Activate panel - await publicChatPanel.activate(); - - // Defer panel swap until animation switch-out completes - set((state) => ({ - chat: { - ...state.chat, - pendingPanel: publicChatPanel, - activeTab: "chats" - } - })); - - // Let MessagePanelRenderer handle the animation timing completely - // It will set isChatSwitching to false when the fadeInDown animation completes - }, - - switchToDM: async (dmData: DMPanelData) => { - const { user, chat } = get(); - - if (!user.authToken) return; - - // Start chat switching animation - chat.setIsSwitching(true); - - // Create or get DM panel - let dmPanel = chat.dmPanel; - if (!dmPanel) { - dmPanel = new DMPanel(user); - } else { - dmPanel.setAuthToken(user.authToken); - // Reset messages for the new DM - dmPanel.clearMessages(); - } - - // Set DM data - dmPanel.setDMData(dmData); - - // Activate panel - await dmPanel.activate(); - - // Defer panel swap until animation switch-out completes - set((state) => ({ - chat: { - ...state.chat, - pendingPanel: dmPanel, - activeDm: { - userId: dmData.userId, - username: dmData.username, - publicKey: dmData.publicKey - }, - activeTab: "chats" - } - })); - - // Let MessagePanelRenderer handle the animation timing completely - // It will set isChatSwitching to false when the fadeInDown animation completes - }, - - // Call state management - startCall: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: true, - status: "calling", - startTime: null, - isMuted: false, - remoteUserId: userId, - remoteUsername: username, - isInitiator: true, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - endCall: () => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: false, - status: "ended", - startTime: null, - isMuted: false, - remoteUserId: null, - remoteUsername: null, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - setCallStatus: (status: CallStatus) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - status, - startTime: status === "active" && !state.chat.call.startTime ? Date.now() : state.chat.call.startTime - } - } - })), - - toggleMute: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMuted: !state.chat.call.isMuted - } - } - })), - - toggleCallMinimize: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMinimized: !state.chat.call.isMinimized - } - } - })), - - receiveCall: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: true, - status: "calling", - startTime: null, - isMuted: false, - remoteUserId: userId, - remoteUsername: username, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - sessionKeyHash, - encryptionEmojis - } - } - })), - - setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - sessionKeyHash - } - } - })), - - toggleVideo: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isVideoEnabled: !state.chat.call.isVideoEnabled - } - } - })), - - toggleScreenShare: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isSharingScreen: !state.chat.call.isSharingScreen - } - } - })), - - setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isRemoteVideoEnabled: enabled - } - } - })), - - setRemoteScreenSharing: (enabled: boolean) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isRemoteScreenSharing: enabled - } - } - })), - toggleCallMinimized: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMinimized: !state.chat.call.isMinimized - } - } - })), - - // Profile dialog state management - setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({ - chat: { - ...state.chat, - profileDialog: data - } - })), - - closeProfileDialog: () => set((state) => ({ - chat: { - ...state.chat, - profileDialog: null - } - })), - - // Online status and typing state management - updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({ - chat: { - ...state.chat, - onlineStatuses: new Map(state.chat.onlineStatuses).set(userId, { online, lastSeen }) - } - })), - - addTypingUser: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - typingUsers: new Map(state.chat.typingUsers).set(userId, username) - } - })), - - removeTypingUser: (userId: number) => set((state) => { - const newTypingUsers = new Map(state.chat.typingUsers); - newTypingUsers.delete(userId); - return { - chat: { - ...state.chat, - typingUsers: newTypingUsers - } - }; - }), - - setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => { - const newDmTypingUsers = new Map(state.chat.dmTypingUsers); - if (isTyping) { - newDmTypingUsers.set(userId, true); - } else { - newDmTypingUsers.delete(userId); - } - return { - chat: { - ...state.chat, - dmTypingUsers: newDmTypingUsers - } - }; - }), - - setSuspended: (reason: string) => set((state) => ({ - user: { - ...state.user, - isSuspended: true, - suspensionReason: reason - } - })) -})); \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 33f8ade..3c489a7 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -4,7 +4,8 @@ import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import { CallWindow } from "./right/calls/CallWindow"; import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import styles from "@/pages/chat/css/layout.module.scss"; @@ -12,7 +13,8 @@ export default function ChatPage() { const { navigate: navigateDownloadApp } = useDownloadAppScreen(); const location = useLocation(); const navigate = useNavigate(); - const { user, setProfileDialog } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const processedProfile = useRef(null); // Handle profile links ONLY from navigation state (from SmartCatchAll) @@ -64,7 +66,7 @@ export default function ChatPage() { } handleProfileLink(); - }, [location.state, user.authToken, user.currentUser?.id, setProfileDialog, navigate, location.pathname]); + }, [location.state, user.authToken, user.currentUser?.id, navigate, location.pathname]); if (navigateDownloadApp) return navigateDownloadApp; diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 11546bb..34234d0 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; -import { useAppState } from "@/pages/chat/state"; -import type { ProfileDialogData } from "@/pages/chat/state"; +import { useProfileStore } from "@/state/profile"; +import { useUserStore } from "@/state/user"; +import type { ProfileDialogData } from "@/state/types"; import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; import { prompt } from "mdui/functions/prompt"; @@ -70,7 +71,8 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol } export function ProfileDialog() { - const { chat, user, closeProfileDialog, setUser } = useAppState(); + const { profileDialog, closeProfileDialog } = useProfileStore(); + const { user, setUser } = useUserStore(); const [isOpen, setIsOpen] = useState(false); const [originalData, setOriginalData] = useState(null); const [currentData, setCurrentData] = useState(null); @@ -80,13 +82,13 @@ export function ProfileDialog() { // Handle dialog open/close based on state useEffect(() => { - if (chat.profileDialog && !isOpen) { + if (profileDialog && !isOpen) { // Fetch fresh data when opening dialog - fetchFreshProfileData(chat.profileDialog); - } else if (!chat.profileDialog && isOpen) { + fetchFreshProfileData(profileDialog); + } else if (!profileDialog && isOpen) { setIsOpen(false); } - }, [chat.profileDialog, isOpen]); + }, [profileDialog, isOpen]); async function fetchFreshProfileData(profileData: ProfileDialogData) { if (!user.authToken) return; diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index 67c476d..d975b0b 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -2,14 +2,16 @@ import { PRODUCT_NAME } from "@/core/config"; import useProfile from "@/pages/chat/hooks/useProfile"; import defaultAvatar from "@/images/default-avatar.png"; import { useState } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar"; import styles from "@/pages/chat/css/left-panel.module.scss"; import logoIcon from "@/images/logo.svg"; export function ChatHeader({ headerRef }: { headerRef?: React.RefObject }) { const { profileData } = useProfile(); - const { setProfileDialog, user } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); function handleProfileClick() { diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index 3287784..1f0fb9d 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -1,4 +1,4 @@ -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { useRef, useState } from "react"; import { SettingsDialog } from "./settings/SettingsDialog"; import { UsernameSearch } from "./UsernameSearch"; @@ -9,7 +9,7 @@ import styles from "@/pages/chat/css/left-panel.module.scss"; function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject }) { const [settingsOpen, onSettingsOpenChange] = useState(false); - const { logout } = useAppState(); + const { logout } = useUserStore(); return ( <> diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 0994cdb..a58c17e 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; import { fetchMessages } from "@/core/api/messaging"; import { fetchUserPublicKey } from "@/core/api/dm"; @@ -42,7 +43,8 @@ const PUBLIC_CHAT: PublicChat = { }; export function UnifiedChatsList() { - const { user, switchToPublicChat, switchToDM, chat } = useAppState(); + const { user } = useUserStore(); + const { switchToPublicChat, switchToDM, activeTab } = useChatStore(); const { dmUsers, isLoadingUsers, loadUsers } = useDM(); const [lastMessages, setLastMessages] = useState>({}); @@ -61,11 +63,11 @@ export function UnifiedChatsList() { }, [user.authToken]); useEffect(() => { - if (chat.activeTab === "chats") { + if (activeTab === "chats") { loadUsers(); loadLastMessages(); } - }, [chat.activeTab, loadUsers, loadLastMessages]); + }, [activeTab, loadUsers, loadLastMessages]); const allChats = useMemo(() => { return [ @@ -155,7 +157,7 @@ export function UnifiedChatsList() { async function handleDMClick(dmConversation: DMConversation) { if (!dmConversation.publicKey) { - const authToken = useAppState.getState().user.authToken; + const authToken = useUserStore.getState().user.authToken; if (!authToken) return; const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index b37d0e1..fc7d60b 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; @@ -23,7 +24,8 @@ export interface UsernameSearchProps { } export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) { - const { user, switchToDM, chat } = useAppState(); + const { user } = useUserStore(); + const { switchToDM, activeDm } = useChatStore(); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); @@ -68,7 +70,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use // Subscribe to online status for all search results useEffect(() => { - const activeDmUserId = chat.activeDm?.userId; + const activeDmUserId = activeDm?.userId; const switchingToUserId = switchingToUserIdRef.current; const currentSearchResultIds = new Set(searchResults.map(u => u.id)); const previousSearchResultIds = new Set(previousSearchResultIdsRef.current); @@ -101,12 +103,12 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use // Clear the ref if the user is now the active DM (state has updated) const finalSwitchingToUserId = switchingToUserIdRef.current; - const finalActiveDmUserId = chat.activeDm?.userId; + const finalActiveDmUserId = activeDm?.userId; if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) { switchingToUserIdRef.current = null; } }; - }, [searchResults, chat.activeDm?.userId]); + }, [searchResults, activeDm?.userId]); async function handleUserClick(searchUser: SearchUser) { diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index 9061e34..db428f3 100644 --- a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx @@ -1,5 +1,5 @@ import { MaterialList, MaterialListItem } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { deleteAccount } from "@/core/api/account"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; @@ -9,7 +9,7 @@ interface AccountPanelProps { } export function AccountPanel({ onClose }: AccountPanelProps) { - const { user, logout } = useAppState(); + const { user, logout } = useUserStore(); const authToken = user?.authToken; async function handleDeleteAccount() { diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index 78a9da6..cc3c52c 100644 --- a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -1,13 +1,13 @@ import { useState } from "react"; import { StyledDialog } from "@/core/components/StyledDialog"; import type { DialogProps } from "@/core/types"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { changePassword } from "@/core/api/account"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) { - const { user } = useAppState(); + const { user } = useUserStore(); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index b6ccc4f..3a5e252 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -1,13 +1,13 @@ import { useState, useEffect } from "react"; import { useImmer } from "use-immer"; import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function DevicesPanel() { - const { user } = useAppState(); + const { user } = useUserStore(); const authToken = user?.authToken ?? null; const [devices, updateDevices] = useImmer([]); const [devicesLoading, setDevicesLoading] = useState(false); diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx index ba5c8bf..3b60c2d 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -1,13 +1,13 @@ import { useState, useRef } from "react"; import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; import { unsubscribeFromPush } from "@/core/api/push"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function NotificationsPanel() { - const { user } = useAppState(); + const { user } = useUserStore(); const authToken = user?.authToken ?? null; const [pushEnabled, setPushEnabled] = useState(false); const [loading, setLoading] = useState(false); diff --git a/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx b/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx index 09b5635..fe034da 100644 --- a/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx @@ -1,8 +1,8 @@ -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; import defaultAvatar from "@/images/default-avatar.png"; export function ChatMainHeader() { - const { currentChat } = useAppState().chat; + const { currentChat } = useChatStore(); return (
diff --git a/frontend/src/pages/chat/ui/right/ChatMessages.tsx b/frontend/src/pages/chat/ui/right/ChatMessages.tsx index 8447153..b3c9421 100644 --- a/frontend/src/pages/chat/ui/right/ChatMessages.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMessages.tsx @@ -1,5 +1,5 @@ import { Message } from "./Message"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import type { Message as MessageType } from "@/core/types"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { useState, type ReactNode } from "react"; @@ -20,7 +20,7 @@ interface ChatMessagesProps { } export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) { - const { user } = useAppState(); + const { user } = useUserStore(); // Context menu state const [contextMenu, setContextMenu] = useState({ diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 137318a..a87837e 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -8,7 +8,8 @@ import { useEffect, useState, useRef, useMemo } from "react"; import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; @@ -25,7 +26,7 @@ interface MessageReactionsProps { } function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) { - const { user } = useAppState(); + const { user } = useUserStore(); const [visibleReactions, setVisibleReactions] = useState([]); const [animatingReactions, setAnimatingReactions] = useState>(new Set()); const [isVisible, setIsVisible] = useState(false); @@ -162,7 +163,8 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD endRect: Rect; } | null>(null); const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); - const { user, setProfileDialog } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const imageRefs = useRef>(new Map()); const dmEnvelope = message.runtimeData?.dmEnvelope; diff --git a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx index 8323483..099cafc 100644 --- a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx +++ b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import type { Message, Size2D } from "@/core/types"; import { EmojiMenu } from "./EmojiMenu"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import styles from "@/pages/chat/css/MessageContextMenu.module.scss"; interface MessageContextMenuProps { @@ -35,7 +35,7 @@ export function MessageContextMenu({ isOpen, onOpenChange }: MessageContextMenuProps) { - const { user } = useAppState(); + const { user } = useUserStore(); // Internal state for closing animation const [isClosing, setIsClosing] = useState(false); const [reactionBarPosition, setReactionBarPosition] = useState({ x: 0, y: 0 }); diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index 7d00e6b..aeb2abc 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -1,6 +1,9 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { motion, AnimatePresence } from "motion/react"; -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; +import { useUserStore } from "@/state/user"; +import { usePresenceStore } from "@/state/presence"; +import { useProfileStore } from "@/state/profile"; import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { ChatMessages } from "./ChatMessages"; import { ChatInputWrapper } from "./ChatInputWrapper"; @@ -23,19 +26,20 @@ interface MessagePanelRendererProps { } function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { - const { chat, user } = useAppState(); + const { typingUsers, dmTypingUsers } = usePresenceStore(); + const { user } = useUserStore(); const otherTypingUsers = useMemo(() => { return Array - .from(chat.typingUsers.entries()) + .from(typingUsers.entries()) .filter(([userId, username]) => userId !== user.currentUser?.id && username) .map(([, username]) => username!); - }, [chat.typingUsers, user.currentUser?.id]); + }, [typingUsers, user.currentUser?.id]); let content: ReactNode; if (panel instanceof DMPanel) { const recipientId = panel.getRecipientId()!; - const isTyping = chat.dmTypingUsers.get(recipientId); + const isTyping = dmTypingUsers.get(recipientId); content = isTyping ? : ; } else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) { @@ -48,7 +52,8 @@ function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { } export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { - const { applyPendingPanel, chat, setProfileDialog } = useAppState(); + const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching } = useChatStore(); + const { setProfileDialog } = useProfileStore(); const messagePanelRef = useRef(null); const [panelState, setPanelState] = useState(null); const messagesEndRef = useRef(null); @@ -121,30 +126,30 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { // Handle chat switching animation useEffect(() => { - if (chat.isSwitching && chat.pendingPanel) { + if (isSwitching && pendingPanel) { // Apply pending panel when animation starts applyPendingPanel(); // End switching state after a brief delay to allow animation setTimeout(() => { - chat.setIsSwitching(false); + setIsSwitching(false); }, 200); } - }, [chat.isSwitching, chat.pendingPanel, applyPendingPanel]); + }, [isSwitching, pendingPanel, applyPendingPanel, setIsSwitching]); // Load messages when panel changes and animation is not running useEffect(() => { - if (!chat.activePanel || chat.isSwitching) return; + if (!activePanel || isSwitching) return; - const panelState = chat.activePanel.getState(); + const panelState = activePanel.getState(); if (panelState.messages.length === 0 && !panelState.isLoading) { - chat.activePanel.loadMessages(); + activePanel.loadMessages(); } - }, [chat.activePanel, chat.isSwitching]); + }, [activePanel, isSwitching]); // Scroll to bottom only when new messages are added useEffect(() => { - if (!panelState || chat.isSwitching) return; + if (!panelState || isSwitching) return; const currentMessageCount = panelState.messages.length; const previousMessageCount = previousMessageCountRef.current; @@ -168,7 +173,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { // Update the previous message count previousMessageCountRef.current = currentMessageCount; - }, [panelState?.messages, panelState?.isLoading, chat.isSwitching]); + }, [panelState?.messages, panelState?.isLoading, isSwitching]); function handleCallClick() { if (panel && panelState && panel.isDm()) { @@ -195,7 +200,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } } - const panelKey = chat.activePanel?.getState().title || "empty"; + const panelKey = activePanel?.getState().title || "empty"; return (
diff --git a/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx b/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx index 49d05f8..cc5da49 100644 --- a/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx +++ b/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx @@ -5,7 +5,7 @@ * @version 1.0.0 */ -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; import styles from "@/pages/chat/css/TypingIndicators.module.scss"; interface OnlineIndicatorProps { @@ -14,8 +14,8 @@ interface OnlineIndicatorProps { } export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) { - const { chat } = useAppState(); - const status = chat.onlineStatuses.get(userId); + const { onlineStatuses } = usePresenceStore(); + const status = onlineStatuses.get(userId); // Only show indicator when user is online if (!status || !status.online) { diff --git a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx index 4b8c028..6aa6615 100644 --- a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx +++ b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx @@ -5,7 +5,8 @@ * @version 1.0.0 */ -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; +import { useUserStore } from "@/state/user"; import styles from "@/pages/chat/css/TypingIndicators.module.scss"; interface OnlineStatusProps { @@ -14,8 +15,9 @@ interface OnlineStatusProps { } export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) { - const { chat, user } = useAppState(); - const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : chat.onlineStatuses.get(userId); + const { onlineStatuses } = usePresenceStore(); + const { user } = useUserStore(); + const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : onlineStatuses.get(userId); function formatLastSeen(lastSeen: string): string { const date = new Date(lastSeen); diff --git a/frontend/src/pages/chat/ui/right/RightPanel.tsx b/frontend/src/pages/chat/ui/right/RightPanel.tsx index caf5f2b..aeaf173 100644 --- a/frontend/src/pages/chat/ui/right/RightPanel.tsx +++ b/frontend/src/pages/chat/ui/right/RightPanel.tsx @@ -1,8 +1,8 @@ -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; import { MessagePanelRenderer } from "./MessagePanelRenderer"; export function RightPanel() { - const { chat } = useAppState(); + const { activePanel } = useChatStore(); - return + return } \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx index 001bf3c..7bd404f 100644 --- a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx +++ b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; +import { useUserStore } from "@/state/user"; import useCall from "@/pages/chat/hooks/useCall"; import defaultAvatar from "@/images/default-avatar.png"; import { createPortal } from "react-dom"; @@ -9,8 +10,8 @@ import { motion, AnimatePresence } from "motion/react"; import styles from "@/pages/chat/css/callWindow.module.scss"; export function CallWindow() { - const { chat, toggleCallMinimize, user } = useAppState(); - const { call } = chat; + const { call, toggleCallMinimized } = useCallStore(); + const { user } = useUserStore(); const { acceptCall, rejectCall, @@ -158,7 +159,7 @@ export function CallWindow() {
diff --git a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx index 16124bf..376a34d 100644 --- a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx +++ b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx @@ -1,11 +1,10 @@ -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; import useCall from "@/pages/chat/hooks/useCall"; import defaultAvatar from "@/images/default-avatar.png"; import { MaterialIconButton } from "@/utils/material"; export function MinimizedCallBar() { - const { chat, toggleCallMinimize } = useAppState(); - const { call } = chat; + const { call, toggleCallMinimized } = useCallStore(); const { endCall, toggleMute } = useCall(); function getGradientClass() { @@ -39,7 +38,7 @@ export function MinimizedCallBar() { } return ( -
+
Avatar
diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index dc841f4..1591e5b 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -9,7 +9,7 @@ import { } from "@/core/api/dm"; import { fetchUserProfileById } from "@/core/api/account/profile"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; import { onlineStatusManager } from "@/core/onlineStatusManager"; import { typingManager } from "@/core/typingManager"; diff --git a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts index 805ed8b..4b38f0d 100644 --- a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts @@ -1,5 +1,5 @@ import type { Message, WebSocketMessage } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; export interface MessagePanelState { id: string; diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index cc89906..3e9e216 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -1,7 +1,7 @@ import { MessagePanel } from "./MessagePanel"; import { request } from "@/core/websocket"; import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging"; export class PublicChatPanel extends MessagePanel { diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index 43ca68e..3aafdb6 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "react-router-dom"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import styles from "./home.module.scss"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import { MaterialButton, MaterialIcon } from "@/utils/material"; @@ -18,7 +18,7 @@ function SupportLink({ children }: { children: React.ReactNode }) { export default function HomePage() { const navigate = useNavigate(); - const { user } = useAppState(); + const { user } = useUserStore(); const { isMobile } = useDownloadAppScreen(); const isLoggedIn = user.authToken && user.currentUser; diff --git a/frontend/src/state/call.ts b/frontend/src/state/call.ts new file mode 100644 index 0000000..688285d --- /dev/null +++ b/frontend/src/state/call.ts @@ -0,0 +1,123 @@ +import { create } from "zustand"; +import type { CallStatus, CallState } from "./types"; + +interface CallStore { + call: CallState; + startCall: (userId: number, username: string) => void; + endCall: () => void; + setCallStatus: (status: CallStatus) => void; + toggleMute: () => void; + toggleCallMinimize: () => void; + receiveCall: (userId: number, username: string) => void; + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void; + setCallSessionKeyHash: (sessionKeyHash: string) => void; + toggleVideo: () => void; + toggleScreenShare: () => void; + setRemoteVideoEnabled: (enabled: boolean) => void; + setRemoteScreenSharing: (enabled: boolean) => void; + toggleCallMinimized: () => void; +} + +const initialCallState: CallState = { + isActive: false, + status: "ended", + startTime: null, + isMuted: false, + remoteUserId: null, + remoteUsername: null, + isInitiator: false, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: false +}; + +export const useCallStore = create((set) => ({ + call: initialCallState, + startCall: (userId: number, username: string) => set({ + call: { + ...initialCallState, + isActive: true, + status: "calling", + remoteUserId: userId, + remoteUsername: username, + isInitiator: true + } + }), + endCall: () => set({ call: initialCallState }), + setCallStatus: (status: CallStatus) => set((state) => ({ + call: { + ...state.call, + status, + startTime: status === "active" && !state.call.startTime ? Date.now() : state.call.startTime + } + })), + toggleMute: () => set((state) => ({ + call: { + ...state.call, + isMuted: !state.call.isMuted + } + })), + toggleCallMinimize: () => set((state) => ({ + call: { + ...state.call, + isMinimized: !state.call.isMinimized + } + })), + receiveCall: (userId: number, username: string) => set({ + call: { + ...initialCallState, + isActive: true, + status: "calling", + remoteUserId: userId, + remoteUsername: username, + isInitiator: false + } + }), + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ + call: { + ...state.call, + sessionKeyHash, + encryptionEmojis + } + })), + setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ + call: { + ...state.call, + sessionKeyHash + } + })), + toggleVideo: () => set((state) => ({ + call: { + ...state.call, + isVideoEnabled: !state.call.isVideoEnabled + } + })), + toggleScreenShare: () => set((state) => ({ + call: { + ...state.call, + isSharingScreen: !state.call.isSharingScreen + } + })), + setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({ + call: { + ...state.call, + isRemoteVideoEnabled: enabled + } + })), + setRemoteScreenSharing: (enabled: boolean) => set((state) => ({ + call: { + ...state.call, + isRemoteScreenSharing: enabled + } + })), + toggleCallMinimized: () => set((state) => ({ + call: { + ...state.call, + isMinimized: !state.call.isMinimized + } + })) +})); diff --git a/frontend/src/state/chat.ts b/frontend/src/state/chat.ts new file mode 100644 index 0000000..74e5218 --- /dev/null +++ b/frontend/src/state/chat.ts @@ -0,0 +1,150 @@ +import { create } from "zustand"; +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "@/pages/chat/ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel"; +import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel"; +import type { DMPanelData } from "@/pages/chat/ui/right/panels/DMPanel"; +import type { ChatTabs, ActiveDM } from "./types"; +import { useUserStore } from "./user"; + +interface ChatStore { + messages: Message[]; + currentChat: string; + activeTab: ChatTabs; + dmUsers: User[]; + activeDm: ActiveDM | null; + isSwitching: boolean; + setIsSwitching: (value: boolean) => void; + activePanel: MessagePanel | null; + publicChatPanel: PublicChatPanel | null; + dmPanel: DMPanel | null; + pendingPanel?: MessagePanel | null; + addMessage: (message: Message) => void; + updateMessage: (messageId: number, updatedMessage: Partial) => void; + removeMessage: (messageId: number) => void; + setCurrentChat: (chat: string) => void; + setActiveTab: (tab: ChatTabs) => void; + setDmUsers: (users: User[]) => void; + setActiveDm: (dm: ActiveDM | null) => void; + clearMessages: () => void; + setActivePanel: (panel: MessagePanel | null) => void; + setPendingPanel: (panel: MessagePanel | null) => void; + applyPendingPanel: () => void; + switchToPublicChat: (chatName: string) => Promise; + switchToDM: (dmData: DMPanelData) => Promise; +} + +export const useChatStore = create((set, get) => ({ + messages: [], + currentChat: "Общий чат", + activeTab: "chats", + dmUsers: [], + activeDm: null, + isSwitching: false, + setIsSwitching: (value: boolean) => set({ isSwitching: value }), + activePanel: null, + publicChatPanel: null, + dmPanel: null, + pendingPanel: null, + addMessage: (message: Message) => set((state) => { + const messageExists = state.messages.some(msg => msg.id === message.id); + if (messageExists) { + return state; + } + return { + messages: [...state.messages, message] + }; + }), + updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ + messages: state.messages.map(msg => + msg.id === messageId ? { ...msg, ...updatedMessage } : msg + ) + })), + removeMessage: (messageId: number) => set((state) => ({ + messages: state.messages.filter(msg => msg.id !== messageId) + })), + clearMessages: () => set({ messages: [] }), + setCurrentChat: (chat: string) => set({ currentChat: chat }), + setActiveTab: (tab: ChatTabs) => set({ activeTab: tab }), + setDmUsers: (users: User[]) => set({ dmUsers: users }), + setActiveDm: (dm: ActiveDM | null) => set({ activeDm: dm }), + setActivePanel: (panel: MessagePanel | null) => { + const state = get(); + if (state.activePanel && state.activePanel !== panel) { + state.activePanel.deactivate(); + } + return set({ activePanel: panel }); + }, + setPendingPanel: (panel: MessagePanel | null) => set({ pendingPanel: panel }), + applyPendingPanel: () => { + const state = get(); + if (state.activePanel) { + state.activePanel.deactivate(); + } + return set((state) => ({ + activePanel: state.pendingPanel || state.activePanel, + publicChatPanel: (state.pendingPanel instanceof PublicChatPanel) + ? (state.pendingPanel as PublicChatPanel) + : state.publicChatPanel, + dmPanel: (state.pendingPanel instanceof DMPanel) + ? (state.pendingPanel as DMPanel) + : state.dmPanel, + currentChat: state.pendingPanel ? state.pendingPanel.getState().title || state.currentChat : state.currentChat, + pendingPanel: null + })); + }, + switchToPublicChat: async (chatName: string) => { + const { user } = useUserStore.getState(); + const state = get(); + + if (!user.authToken) return; + + state.setIsSwitching(true); + + let publicChatPanel = state.publicChatPanel; + if (!publicChatPanel) { + publicChatPanel = new PublicChatPanel(chatName, user); + } else { + publicChatPanel.setChatName(chatName); + publicChatPanel.setAuthToken(user.authToken); + publicChatPanel.clearMessages(); + } + + await publicChatPanel.activate(); + + set({ + pendingPanel: publicChatPanel, + activeTab: "chats" + }); + }, + switchToDM: async (dmData: DMPanelData) => { + const { user } = useUserStore.getState(); + const state = get(); + + if (!user.authToken) return; + + state.setIsSwitching(true); + + let dmPanel = state.dmPanel; + if (!dmPanel) { + dmPanel = new DMPanel(user); + } else { + dmPanel.setAuthToken(user.authToken); + dmPanel.clearMessages(); + } + + dmPanel.setDMData(dmData); + + await dmPanel.activate(); + + set({ + pendingPanel: dmPanel, + activeDm: { + userId: dmData.userId, + username: dmData.username, + publicKey: dmData.publicKey + }, + activeTab: "chats" + }); + } +})); diff --git a/frontend/src/state/presence.ts b/frontend/src/state/presence.ts new file mode 100644 index 0000000..9220f00 --- /dev/null +++ b/frontend/src/state/presence.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; + +interface PresenceStore { + onlineStatuses: Map; + typingUsers: Map; // userId -> username + dmTypingUsers: Map; + updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void; + addTypingUser: (userId: number, username: string) => void; + removeTypingUser: (userId: number) => void; + setDmTypingUser: (userId: number, isTyping: boolean) => void; +} + +export const usePresenceStore = create((set) => ({ + onlineStatuses: new Map(), + typingUsers: new Map(), + dmTypingUsers: new Map(), + updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({ + onlineStatuses: new Map(state.onlineStatuses).set(userId, { online, lastSeen }) + })), + addTypingUser: (userId: number, username: string) => set((state) => ({ + typingUsers: new Map(state.typingUsers).set(userId, username) + })), + removeTypingUser: (userId: number) => set((state) => { + const newTypingUsers = new Map(state.typingUsers); + newTypingUsers.delete(userId); + return { + typingUsers: newTypingUsers + }; + }), + setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => { + const newDmTypingUsers = new Map(state.dmTypingUsers); + if (isTyping) { + newDmTypingUsers.set(userId, true); + } else { + newDmTypingUsers.delete(userId); + } + return { + dmTypingUsers: newDmTypingUsers + }; + }) +})); diff --git a/frontend/src/state/profile.ts b/frontend/src/state/profile.ts new file mode 100644 index 0000000..35a6314 --- /dev/null +++ b/frontend/src/state/profile.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; +import type { ProfileDialogData } from "./types"; + +interface ProfileStore { + profileDialog: ProfileDialogData | null; + setProfileDialog: (data: ProfileDialogData | null) => void; + closeProfileDialog: () => void; +} + +export const useProfileStore = create((set) => ({ + profileDialog: null, + setProfileDialog: (data: ProfileDialogData | null) => set({ profileDialog: data }), + closeProfileDialog: () => set({ profileDialog: null }) +})); diff --git a/frontend/src/state/types.ts b/frontend/src/state/types.ts new file mode 100644 index 0000000..eb251f2 --- /dev/null +++ b/frontend/src/state/types.ts @@ -0,0 +1,73 @@ +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "@/pages/chat/ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel"; +import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel"; + +export type ChatTabs = "chats" | "channels" | "contacts"; + +export type CallStatus = "calling" | "connecting" | "active" | "ended"; + +export interface ProfileDialogData { + userId?: number; + username?: string; + display_name?: string; + profilePicture?: string; + bio?: string; + memberSince?: string; + online?: boolean; + isOwnProfile: boolean; + verified?: boolean; + suspended?: boolean; + suspension_reason?: string | null; + deleted?: boolean; +} + +export interface ActiveDM { + userId: number; + username: string; + publicKey: string | null; +} + +export interface CallState { + isActive: boolean; + status: CallStatus; + startTime: number | null; + isMuted: boolean; + remoteUserId: number | null; + remoteUsername: string | null; + isInitiator: boolean; + isMinimized: boolean; + sessionKeyHash: string | null; + encryptionEmojis: string[]; + isVideoEnabled: boolean; + isRemoteVideoEnabled: boolean; + isSharingScreen: boolean; + isRemoteScreenSharing: boolean; +} + +export interface ChatState { + messages: Message[]; + currentChat: string; + activeTab: ChatTabs; + dmUsers: User[]; + activeDm: ActiveDM | null; + isSwitching: boolean; + setIsSwitching: (value: boolean) => void; + activePanel: MessagePanel | null; + publicChatPanel: PublicChatPanel | null; + dmPanel: DMPanel | null; + pendingPanel?: MessagePanel | null; + call: CallState; + profileDialog: ProfileDialogData | null; + onlineStatuses: Map; + typingUsers: Map; // userId -> username + dmTypingUsers: Map; +} + +export interface UserState { + currentUser: User | null; + authToken: string | null; + isSuspended: boolean; + suspensionReason: string | null; +} + diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts new file mode 100644 index 0000000..bffd7ca --- /dev/null +++ b/frontend/src/state/user.ts @@ -0,0 +1,159 @@ +import { create } from "zustand"; +import type { User } from "@/core/types"; +import { request } from "@/core/websocket"; +import { restoreKeys } from "@/core/api/account"; +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "@/core/api/account"; +import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; +import { isElectron } from "@/core/electron/electron"; +import { onlineStatusManager } from "@/core/onlineStatusManager"; +import { typingManager } from "@/core/typingManager"; +import type { UserState } from "./types"; + +interface UserStore { + user: UserState; + setUser: (token: string, user: User) => void; + logout: () => void; + restoreFromStorage: () => Promise; + setSuspended: (reason: string) => void; +} + +export const useUserStore = create((set) => ({ + user: { + currentUser: null, + authToken: null, + isSuspended: false, + suspensionReason: null + }, + setUser: (token: string, user: User) => { + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: user.suspended || false, + suspensionReason: user.suspension_reason || null + } + }); + + onlineStatusManager.setAuthToken(token); + typingManager.setAuthToken(token); + + try { + localStorage.setItem('authToken', token); + localStorage.setItem('currentUser', JSON.stringify(user)); + } catch (error) { + console.error('Failed to store credentials in localStorage:', error); + } + + try { + request({ + type: "ping", + credentials: { + scheme: "Bearer", + credentials: token + }, + data: {} + }) + } catch {} + }, + logout: () => { + try { + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + } catch (error) { + console.error('Failed to clear localStorage:', error); + } + + onlineStatusManager.setAuthToken(null); + typingManager.setAuthToken(null); + onlineStatusManager.cleanup(); + typingManager.cleanup(); + + set({ + user: { + currentUser: null, + authToken: null, + isSuspended: false, + suspensionReason: null + } + }); + }, + restoreFromStorage: async () => { + try { + const token = localStorage.getItem('authToken'); + + if (token) { + const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) + }); + if (fullResponse.ok) { + const user: User = await fullResponse.json(); + restoreKeys(); + + if (user.suspended) { + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: true, + suspensionReason: user.suspension_reason || null + } + }); + return; + } + + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: false, + suspensionReason: null + } + }); + + onlineStatusManager.setAuthToken(token); + typingManager.setAuthToken(token); + + try { + request({ + type: "ping", + credentials: { + scheme: "Bearer", + credentials: token + }, + data: {} + }) + } catch {} + + try { + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(token); + + if (isElectron) { + await startElectronReceiver(); + } + } + } + } catch (e) { + console.error("Notification setup failed (restored):", e); + } + } else { + throw new Error("Unable to authenticate"); + } + } + } catch (error) { + console.error('Failed to restore user from localStorage:', error); + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + } + }, + setSuspended: (reason: string) => set((state) => ({ + user: { + ...state.user, + isSuspended: true, + suspensionReason: reason + } + })) +}));