Refactor state

This commit is contained in:
2025-11-18 23:21:30 +03:00
Unverified
parent c6c60e6404
commit dc4536cabc
43 changed files with 720 additions and 866 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom"; import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { ElectronTitleBar } from "./Electron"; import { ElectronTitleBar } from "./Electron";
import { useAppState } from "./pages/chat/state"; import { useUserStore } from "./state/user";
import { lazy, useEffect, useRef, useState } from "react"; import { lazy, useEffect, useRef, useState } from "react";
import { parseProfileLink } from "./core/profileLinks"; import { parseProfileLink } from "./core/profileLinks";
import NotFoundPage from "./pages/not-found/NotFoundPage"; import NotFoundPage from "./pages/not-found/NotFoundPage";
@@ -117,14 +117,14 @@ function AnimatedRoutes() {
} }
export default function App() { export default function App() {
const { restoreUserFromStorage, user } = useAppState(); const { restoreFromStorage, user } = useUserStore();
const [authReady, setAuthReady] = useState(false); const [authReady, setAuthReady] = useState(false);
useEffect(() => { useEffect(() => {
restoreUserFromStorage().finally(() => { restoreFromStorage().finally(() => {
setAuthReady(true); setAuthReady(true);
}); });
}, [restoreUserFromStorage]); }, [restoreFromStorage]);
return authReady && ( return authReady && (
<BrowserRouter> <BrowserRouter>
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/account/profile"; import { checkUserSimilarity } from "@/core/api/account/profile";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import { MaterialIcon } from "@/utils/material"; import { MaterialIcon } from "@/utils/material";
interface StatusBadgeProps { interface StatusBadgeProps {
@@ -11,7 +11,7 @@ interface StatusBadgeProps {
export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) { export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) {
const [isSimilarToVerified, setIsSimilarToVerified] = useState(false); const [isSimilarToVerified, setIsSimilarToVerified] = useState(false);
const { user } = useAppState(); const { user } = useUserStore();
const className = `status-badge ${size}`; const className = `status-badge ${size}`;
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { verifyUser } from "@/core/api/account/profile"; import { verifyUser } from "@/core/api/account/profile";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import { MaterialButton } from "@/utils/material"; import { MaterialButton } from "@/utils/material";
interface VerifyButtonProps { interface VerifyButtonProps {
@@ -11,7 +11,7 @@ interface VerifyButtonProps {
export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) { export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) {
const [isVerifying, setIsVerifying] = useState(false); const [isVerifying, setIsVerifying] = useState(false);
const { user } = useAppState(); const { user } = useUserStore();
// Only show for owner // Only show for owner
if (user.currentUser?.id !== 1) { if (user.currentUser?.id !== 1) {
+2 -2
View File
@@ -11,7 +11,7 @@ import type {
SubscribeStatusWebSocketMessage, SubscribeStatusWebSocketMessage,
UnsubscribeStatusWebSocketMessage UnsubscribeStatusWebSocketMessage
} from "./types"; } from "./types";
import { useAppState } from "@/pages/chat/state"; import { usePresenceStore } from "@/state/presence";
export interface UserStatus { export interface UserStatus {
online: boolean; online: boolean;
@@ -96,7 +96,7 @@ export class OnlineStatusManager {
this.statusCache.set(userId, { online, lastSeen }); this.statusCache.set(userId, { online, lastSeen });
// Update the global state // Update the global state
const { updateOnlineStatus } = useAppState.getState(); const { updateOnlineStatus } = usePresenceStore.getState();
updateOnlineStatus(userId, online, lastSeen); updateOnlineStatus(userId, online, lastSeen);
} }
+5 -5
View File
@@ -16,7 +16,7 @@ import type {
DmTypingRequest, DmTypingRequest,
StopDmTypingRequest StopDmTypingRequest
} from "./types"; } from "./types";
import { useAppState } from "@/pages/chat/state"; import { usePresenceStore } from "@/state/presence";
/** /**
* Manages typing indicators for public chat and DMs * Manages typing indicators for public chat and DMs
@@ -133,7 +133,7 @@ export class TypingManager {
* Handle incoming typing indicator from WebSocket * Handle incoming typing indicator from WebSocket
*/ */
handleTyping(message: TypingWebSocketMessage): void { handleTyping(message: TypingWebSocketMessage): void {
const { addTypingUser } = useAppState.getState(); const { addTypingUser } = usePresenceStore.getState();
addTypingUser(message.data.userId, message.data.username); addTypingUser(message.data.userId, message.data.username);
} }
@@ -141,7 +141,7 @@ export class TypingManager {
* Handle incoming stop typing indicator from WebSocket * Handle incoming stop typing indicator from WebSocket
*/ */
handleStopTyping(message: StopTypingWebSocketMessage): void { handleStopTyping(message: StopTypingWebSocketMessage): void {
const { removeTypingUser } = useAppState.getState(); const { removeTypingUser } = usePresenceStore.getState();
removeTypingUser(message.data.userId); removeTypingUser(message.data.userId);
} }
@@ -149,7 +149,7 @@ export class TypingManager {
* Handle incoming DM typing indicator from WebSocket * Handle incoming DM typing indicator from WebSocket
*/ */
handleDmTyping(message: DmTypingWebSocketMessage): void { handleDmTyping(message: DmTypingWebSocketMessage): void {
const { setDmTypingUser } = useAppState.getState(); const { setDmTypingUser } = usePresenceStore.getState();
setDmTypingUser(message.data.userId, true); setDmTypingUser(message.data.userId, true);
} }
@@ -157,7 +157,7 @@ export class TypingManager {
* Handle incoming stop DM typing indicator from WebSocket * Handle incoming stop DM typing indicator from WebSocket
*/ */
handleStopDmTyping(message: StopDmTypingWebSocketMessage): void { handleStopDmTyping(message: StopDmTypingWebSocketMessage): void {
const { setDmTypingUser } = useAppState.getState(); const { setDmTypingUser } = usePresenceStore.getState();
setDmTypingUser(message.data.userId, false); setDmTypingUser(message.data.userId, false);
} }
+3 -3
View File
@@ -11,7 +11,7 @@ import { delay } from "@/utils/utils";
import { CallSignalingHandler } from "./calls/signaling"; import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager"; import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager"; import { typingManager } from "./typingManager";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
/** /**
* Creates a new WebSocket connection to the chat server * Creates a new WebSocket connection to the chat server
@@ -170,14 +170,14 @@ function setupEventHandlers(): void {
typingManager.handleStopDmTyping(response as any); typingManager.handleStopDmTyping(response as any);
} else if (response.type === "suspended") { } else if (response.type === "suspended") {
// Handle account suspension // Handle account suspension
const { setSuspended } = useAppState.getState(); const { setSuspended } = useUserStore.getState();
const reason = response.data?.reason || "No reason provided"; const reason = response.data?.reason || "No reason provided";
setSuspended(reason); setSuspended(reason);
// Close WebSocket connection // Close WebSocket connection
websocket.close(); websocket.close();
} else if (response.type === "account_deleted") { } else if (response.type === "account_deleted") {
// Handle account deletion - silent logout // Handle account deletion - silent logout
const { logout } = useAppState.getState(); const { logout } = useUserStore.getState();
logout(); logout();
// Close WebSocket connection // Close WebSocket connection
websocket.close(); websocket.close();
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useAppState } from "./chat/state"; import { useUserStore } from "@/state/user";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
interface ProtectedRouteProps { interface ProtectedRouteProps {
@@ -7,7 +7,7 @@ interface ProtectedRouteProps {
} }
export default function ProtectedRoute({ children }: ProtectedRouteProps) { export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user } = useAppState(); const { user } = useUserStore();
return !user.authToken ? <Navigate to="/login" /> : children; return !user.authToken ? <Navigate to="/login" /> : children;
} }
+3 -3
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { motion, type Transition, type Variants } from "motion/react"; import { motion, type Transition, type Variants } from "motion/react";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import type { LoginRequest } from "@/core/types"; import type { LoginRequest } from "@/core/types";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import { MaterialButton } from "@/utils/material"; import { MaterialButton } from "@/utils/material";
import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account";
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
@@ -53,7 +53,7 @@ interface LoginFormProps {
export function LoginForm({ onSwitchMode }: LoginFormProps) { export function LoginForm({ onSwitchMode }: LoginFormProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [alerts, updateAlerts] = useImmer<Alert[]>([]); const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setUser = useAppState(state => state.setUser); const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate(); const navigate = useNavigate();
function showAlert(type: AlertType, message: string) { function showAlert(type: AlertType, message: string) {
@@ -119,7 +119,7 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
} }
} catch (error: any) { } catch (error: any) {
if (error.message && error.message.includes("suspension")) { if (error.message && error.message.includes("suspension")) {
const setSuspended = useAppState.getState().setSuspended; const setSuspended = useUserStore.getState().setSuspended;
setSuspended(error.message || "No reason provided"); setSuspended(error.message || "No reason provided");
return; return;
} }
+2 -2
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { motion, type Transition, type Variants } from "motion/react"; import { motion, type Transition, type Variants } from "motion/react";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import type { RegisterRequest } from "@/core/types"; import type { RegisterRequest } from "@/core/types";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import { MaterialButton, MaterialIconButton } from "@/utils/material"; import { MaterialButton, MaterialIconButton } from "@/utils/material";
import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account";
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
@@ -51,7 +51,7 @@ interface RegisterFormProps {
export function RegisterForm({ onSwitchMode }: RegisterFormProps) { export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [alerts, updateAlerts] = useImmer<Alert[]>([]); const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setUser = useAppState(state => state.setUser); const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate(); const navigate = useNavigate();
function showAlert(type: AlertType, message: string) { function showAlert(type: AlertType, message: string) {
+30 -29
View File
@@ -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 * as WebRTC from "@/core/calls/webrtc";
import { CallSignalingHandler } from "@/core/calls/signaling"; import { CallSignalingHandler } from "@/core/calls/signaling";
import { setCallSignalingHandler } from "@/core/websocket"; import { setCallSignalingHandler } from "@/core/websocket";
@@ -15,7 +16,7 @@ let globalRemoteScreenShareRef = createRef<HTMLVideoElement>();
export default function useCall() { export default function useCall() {
const { const {
chat, call,
startCall, startCall,
endCall, endCall,
setCallStatus, setCallStatus,
@@ -26,8 +27,9 @@ export default function useCall() {
setCallSessionKeyHash, setCallSessionKeyHash,
setRemoteVideoEnabled, setRemoteVideoEnabled,
setRemoteScreenSharing, setRemoteScreenSharing,
user receiveCall
} = useAppState(); } = useCallStore();
const { user } = useUserStore();
const remoteAudioRef = globalRemoteAudioRef; const remoteAudioRef = globalRemoteAudioRef;
const localVideoRef = globalLocalVideoRef; const localVideoRef = globalLocalVideoRef;
@@ -40,8 +42,7 @@ export default function useCall() {
const signalingHandler = new CallSignalingHandler(() => ({ const signalingHandler = new CallSignalingHandler(() => ({
receiveCall: (userId: number, username: string) => { receiveCall: (userId: number, username: string) => {
// Use the receiveCall function from state // Use the receiveCall function from state
const state = useAppState.getState(); receiveCall(userId, username);
state.receiveCall(userId, username);
}, },
endCall, endCall,
setCallSessionKeyHash, setCallSessionKeyHash,
@@ -52,8 +53,8 @@ export default function useCall() {
// Set up call state change handler // Set up call state change handler
WebRTC.callbacks.onCallStateChange = (userId: number, state: string) => { WebRTC.callbacks.onCallStateChange = (userId: number, state: string) => {
const call = chat.call; const currentCall = call;
if (call.remoteUserId === userId) { if (currentCall.remoteUserId === userId) {
switch (state) { switch (state) {
case "connecting": case "connecting":
setCallStatus("connecting"); setCallStatus("connecting");
@@ -183,15 +184,15 @@ export default function useCall() {
WebRTC.cleanup(); WebRTC.cleanup();
setCallSignalingHandler(null); 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 // Watch for session key hash changes and generate emojis
useEffect(() => { useEffect(() => {
if (chat.call.sessionKeyHash && chat.call.encryptionEmojis.length === 0) { if (call.sessionKeyHash && call.encryptionEmojis.length === 0) {
const emojis = generateCallEmojis(chat.call.sessionKeyHash); const emojis = generateCallEmojis(call.sessionKeyHash);
setCallEncryption(chat.call.sessionKeyHash, emojis); setCallEncryption(call.sessionKeyHash, emojis);
} }
}, [chat.call.sessionKeyHash, chat.call.encryptionEmojis.length, setCallEncryption]); }, [call.sessionKeyHash, call.encryptionEmojis.length, setCallEncryption]);
async function requestAudioPermissions(): Promise<boolean> { async function requestAudioPermissions(): Promise<boolean> {
try { try {
@@ -249,12 +250,12 @@ export default function useCall() {
} }
async function acceptCall() { async function acceptCall() {
if (!chat.call.remoteUserId) { if (!call.remoteUserId) {
return; return;
} }
setCallStatus("connecting"); setCallStatus("connecting");
const success = await WebRTC.acceptCall(chat.call.remoteUserId); const success = await WebRTC.acceptCall(call.remoteUserId);
if (!success) { if (!success) {
endCall(); endCall();
@@ -262,46 +263,46 @@ export default function useCall() {
} }
async function rejectCall() { async function rejectCall() {
if (!chat.call.remoteUserId) { if (!call.remoteUserId) {
return; return;
} }
await WebRTC.rejectCall(chat.call.remoteUserId); await WebRTC.rejectCall(call.remoteUserId);
endCall(); endCall();
} }
async function handleEndCall() { async function handleEndCall() {
if (chat.call.remoteUserId) { if (call.remoteUserId) {
await WebRTC.endCall(chat.call.remoteUserId); await WebRTC.endCall(call.remoteUserId);
} }
endCall(); endCall();
} }
function handleToggleMute() { function handleToggleMute() {
if (chat.call.remoteUserId) { if (call.remoteUserId) {
const isMuted = WebRTC.toggleMute(chat.call.remoteUserId); const isMuted = WebRTC.toggleMute(call.remoteUserId);
// Update mute state in store // Update mute state in store
if (isMuted !== chat.call.isMuted) { if (isMuted !== call.isMuted) {
toggleMute(); toggleMute();
} }
} }
} }
async function handleToggleVideo() { async function handleToggleVideo() {
if (chat.call.remoteUserId) { if (call.remoteUserId) {
const isEnabled = await WebRTC.toggleVideo(chat.call.remoteUserId); const isEnabled = await WebRTC.toggleVideo(call.remoteUserId);
// Update video state in store // Update video state in store
if (isEnabled !== chat.call.isVideoEnabled) { if (isEnabled !== call.isVideoEnabled) {
toggleVideo(); toggleVideo();
} }
} }
} }
async function handleToggleScreenShare() { async function handleToggleScreenShare() {
if (chat.call.remoteUserId) { if (call.remoteUserId) {
const isEnabled = await WebRTC.toggleScreenShare(chat.call.remoteUserId); const isEnabled = await WebRTC.toggleScreenShare(call.remoteUserId);
// Update screen share state in store // Update screen share state in store
if (isEnabled !== chat.call.isSharingScreen) { if (isEnabled !== call.isSharingScreen) {
toggleScreenShare(); toggleScreenShare();
} }
} }
@@ -336,7 +337,7 @@ export default function useCall() {
} }
return { return {
call: chat.call, call: call,
initiateCall, initiateCall,
acceptCall, acceptCall,
rejectCall, rejectCall,
+5 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat";
import { import {
fetchUserPublicKey, fetchUserPublicKey,
fetchDMHistory, fetchDMHistory,
@@ -44,7 +45,8 @@ export function formatDMMessageContent(
} }
export function useDM() { export function useDM() {
const { user, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState(); const { user } = useUserStore();
const { setDmUsers, setActiveDm, addMessage, clearMessages } = useChatStore();
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]); const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false); const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = 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 // If conversation no longer exists, remove the user from the list
setDmUsersState(prev => prev.filter(u => u.id !== userId)); setDmUsersState(prev => prev.filter(u => u.id !== userId));
// Get current dmUsers and filter out the removed user // 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)); setDmUsers(currentDmUsers.filter((u: User) => u.id !== userId));
} }
} catch (error) { } catch (error) {
+2 -2
View File
@@ -1,10 +1,10 @@
import { useState, useCallback, useEffect } from "react"; 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 { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile";
import { showSuccess, showError } from "@/utils/notification"; import { showSuccess, showError } from "@/utils/notification";
export default function useProfile() { export default function useProfile() {
const { user } = useAppState(); const { user } = useUserStore();
const [profileData, setProfileData] = useState<ProfileData | null>(null); const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
-728
View File
@@ -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<number, {online: boolean, lastSeen: string}>;
typingUsers: Map<number, string>; // userId -> username
dmTypingUsers: Map<number, boolean>;
}
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<Message>) => 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<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
// 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<void>;
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<AppState>((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<Message>) => 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
}
}))
}));
+5 -3
View File
@@ -4,7 +4,8 @@ import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { CallWindow } from "./right/calls/CallWindow"; import { CallWindow } from "./right/calls/CallWindow";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom"; 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 { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile";
import styles from "@/pages/chat/css/layout.module.scss"; import styles from "@/pages/chat/css/layout.module.scss";
@@ -12,7 +13,8 @@ export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen(); const { navigate: navigateDownloadApp } = useDownloadAppScreen();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, setProfileDialog } = useAppState(); const { user } = useUserStore();
const { setProfileDialog } = useProfileStore();
const processedProfile = useRef<string | null>(null); const processedProfile = useRef<string | null>(null);
// Handle profile links ONLY from navigation state (from SmartCatchAll) // Handle profile links ONLY from navigation state (from SmartCatchAll)
@@ -64,7 +66,7 @@ export default function ChatPage() {
} }
handleProfileLink(); 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; if (navigateDownloadApp) return navigateDownloadApp;
+9 -7
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { useAppState } from "@/pages/chat/state"; import { useProfileStore } from "@/state/profile";
import type { ProfileDialogData } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import type { ProfileDialogData } from "@/state/types";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { confirm } from "mdui/functions/confirm"; import { confirm } from "mdui/functions/confirm";
import { prompt } from "mdui/functions/prompt"; import { prompt } from "mdui/functions/prompt";
@@ -70,7 +71,8 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
} }
export function ProfileDialog() { export function ProfileDialog() {
const { chat, user, closeProfileDialog, setUser } = useAppState(); const { profileDialog, closeProfileDialog } = useProfileStore();
const { user, setUser } = useUserStore();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null); const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null); const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
@@ -80,13 +82,13 @@ export function ProfileDialog() {
// Handle dialog open/close based on state // Handle dialog open/close based on state
useEffect(() => { useEffect(() => {
if (chat.profileDialog && !isOpen) { if (profileDialog && !isOpen) {
// Fetch fresh data when opening dialog // Fetch fresh data when opening dialog
fetchFreshProfileData(chat.profileDialog); fetchFreshProfileData(profileDialog);
} else if (!chat.profileDialog && isOpen) { } else if (!profileDialog && isOpen) {
setIsOpen(false); setIsOpen(false);
} }
}, [chat.profileDialog, isOpen]); }, [profileDialog, isOpen]);
async function fetchFreshProfileData(profileData: ProfileDialogData) { async function fetchFreshProfileData(profileData: ProfileDialogData) {
if (!user.authToken) return; if (!user.authToken) return;
@@ -2,14 +2,16 @@ import { PRODUCT_NAME } from "@/core/config";
import useProfile from "@/pages/chat/hooks/useProfile"; import useProfile from "@/pages/chat/hooks/useProfile";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react"; 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 { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
import styles from "@/pages/chat/css/left-panel.module.scss"; import styles from "@/pages/chat/css/left-panel.module.scss";
import logoIcon from "@/images/logo.svg"; import logoIcon from "@/images/logo.svg";
export function ChatHeader({ headerRef }: { headerRef?: React.RefObject<HTMLElement | null> }) { export function ChatHeader({ headerRef }: { headerRef?: React.RefObject<HTMLElement | null> }) {
const { profileData } = useProfile(); const { profileData } = useProfile();
const { setProfileDialog, user } = useAppState(); const { user } = useUserStore();
const { setProfileDialog } = useProfileStore();
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
function handleProfileClick() { function handleProfileClick() {
@@ -1,4 +1,4 @@
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { SettingsDialog } from "./settings/SettingsDialog"; import { SettingsDialog } from "./settings/SettingsDialog";
import { UsernameSearch } from "./UsernameSearch"; import { UsernameSearch } from "./UsernameSearch";
@@ -9,7 +9,7 @@ import styles from "@/pages/chat/css/left-panel.module.scss";
function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null> }) { function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null> }) {
const [settingsOpen, onSettingsOpenChange] = useState(false); const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useAppState(); const { logout } = useUserStore();
return ( return (
<> <>
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from "react"; 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 { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { fetchMessages } from "@/core/api/messaging"; import { fetchMessages } from "@/core/api/messaging";
import { fetchUserPublicKey } from "@/core/api/dm"; import { fetchUserPublicKey } from "@/core/api/dm";
@@ -42,7 +43,8 @@ const PUBLIC_CHAT: PublicChat = {
}; };
export function UnifiedChatsList() { export function UnifiedChatsList() {
const { user, switchToPublicChat, switchToDM, chat } = useAppState(); const { user } = useUserStore();
const { switchToPublicChat, switchToDM, activeTab } = useChatStore();
const { dmUsers, isLoadingUsers, loadUsers } = useDM(); const { dmUsers, isLoadingUsers, loadUsers } = useDM();
const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({}); const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({});
@@ -61,11 +63,11 @@ export function UnifiedChatsList() {
}, [user.authToken]); }, [user.authToken]);
useEffect(() => { useEffect(() => {
if (chat.activeTab === "chats") { if (activeTab === "chats") {
loadUsers(); loadUsers();
loadLastMessages(); loadLastMessages();
} }
}, [chat.activeTab, loadUsers, loadLastMessages]); }, [activeTab, loadUsers, loadLastMessages]);
const allChats = useMemo<ChatItem[]>(() => { const allChats = useMemo<ChatItem[]>(() => {
return [ return [
@@ -155,7 +157,7 @@ export function UnifiedChatsList() {
async function handleDMClick(dmConversation: DMConversation) { async function handleDMClick(dmConversation: DMConversation) {
if (!dmConversation.publicKey) { if (!dmConversation.publicKey) {
const authToken = useAppState.getState().user.authToken; const authToken = useUserStore.getState().user.authToken;
if (!authToken) return; if (!authToken) return;
const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); const publicKey = await fetchUserPublicKey(dmConversation.id, authToken);
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react"; 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 { searchUsers, fetchUserPublicKey } from "@/core/api/dm";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import type { User } from "@/core/types"; import type { User } from "@/core/types";
@@ -23,7 +24,8 @@ export interface UsernameSearchProps {
} }
export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: 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 [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchUser[]>([]); const [searchResults, setSearchResults] = useState<SearchUser[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -68,7 +70,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use
// Subscribe to online status for all search results // Subscribe to online status for all search results
useEffect(() => { useEffect(() => {
const activeDmUserId = chat.activeDm?.userId; const activeDmUserId = activeDm?.userId;
const switchingToUserId = switchingToUserIdRef.current; const switchingToUserId = switchingToUserIdRef.current;
const currentSearchResultIds = new Set(searchResults.map(u => u.id)); const currentSearchResultIds = new Set(searchResults.map(u => u.id));
const previousSearchResultIds = new Set(previousSearchResultIdsRef.current); 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) // Clear the ref if the user is now the active DM (state has updated)
const finalSwitchingToUserId = switchingToUserIdRef.current; const finalSwitchingToUserId = switchingToUserIdRef.current;
const finalActiveDmUserId = chat.activeDm?.userId; const finalActiveDmUserId = activeDm?.userId;
if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) { if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) {
switchingToUserIdRef.current = null; switchingToUserIdRef.current = null;
} }
}; };
}, [searchResults, chat.activeDm?.userId]); }, [searchResults, activeDm?.userId]);
async function handleUserClick(searchUser: SearchUser) { async function handleUserClick(searchUser: SearchUser) {
@@ -1,5 +1,5 @@
import { MaterialList, MaterialListItem } from "@/utils/material"; import { MaterialList, MaterialListItem } from "@/utils/material";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import { deleteAccount } from "@/core/api/account"; import { deleteAccount } from "@/core/api/account";
import { confirm } from "mdui/functions/confirm"; import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss"; import styles from "@/pages/chat/css/settings-dialog.module.scss";
@@ -9,7 +9,7 @@ interface AccountPanelProps {
} }
export function AccountPanel({ onClose }: AccountPanelProps) { export function AccountPanel({ onClose }: AccountPanelProps) {
const { user, logout } = useAppState(); const { user, logout } = useUserStore();
const authToken = user?.authToken; const authToken = user?.authToken;
async function handleDeleteAccount() { async function handleDeleteAccount() {
@@ -1,13 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { StyledDialog } from "@/core/components/StyledDialog"; import { StyledDialog } from "@/core/components/StyledDialog";
import type { DialogProps } from "@/core/types"; import type { DialogProps } from "@/core/types";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import { changePassword } from "@/core/api/account"; import { changePassword } from "@/core/api/account";
import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material";
import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss";
export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) { export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) {
const { user } = useAppState(); const { user } = useUserStore();
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
const [next, setNext] = useState(""); const [next, setNext] = useState("");
@@ -1,13 +1,13 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; 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 { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices";
import { confirm } from "mdui/functions/confirm"; import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss"; import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function DevicesPanel() { export function DevicesPanel() {
const { user } = useAppState(); const { user } = useUserStore();
const authToken = user?.authToken ?? null; const authToken = user?.authToken ?? null;
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]); const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false); const [devicesLoading, setDevicesLoading] = useState(false);
@@ -1,13 +1,13 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from "@/utils/material"; 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 { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; import { isElectron } from "@/core/electron/electron";
import { unsubscribeFromPush } from "@/core/api/push"; import { unsubscribeFromPush } from "@/core/api/push";
import styles from "@/pages/chat/css/settings-dialog.module.scss"; import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function NotificationsPanel() { export function NotificationsPanel() {
const { user } = useAppState(); const { user } = useUserStore();
const authToken = user?.authToken ?? null; const authToken = user?.authToken ?? null;
const [pushEnabled, setPushEnabled] = useState(false); const [pushEnabled, setPushEnabled] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -1,8 +1,8 @@
import { useAppState } from "@/pages/chat/state"; import { useChatStore } from "@/state/chat";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
export function ChatMainHeader() { export function ChatMainHeader() {
const { currentChat } = useAppState().chat; const { currentChat } = useChatStore();
return ( return (
<div className="chat-header"> <div className="chat-header">
@@ -1,5 +1,5 @@
import { Message } from "./Message"; import { Message } from "./Message";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import type { Message as MessageType } from "@/core/types"; import type { Message as MessageType } from "@/core/types";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { useState, type ReactNode } from "react"; 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) { export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { user } = useAppState(); const { user } = useUserStore();
// Context menu state // Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({ const [contextMenu, setContextMenu] = useState<ContextMenuState>({
+5 -3
View File
@@ -8,7 +8,8 @@ import { useEffect, useState, useRef, useMemo } from "react";
import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; import { getCurrentKeys, getAuthHeaders } from "@/core/api/account";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; 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 { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
import { ub64 } from "@/utils/utils"; import { ub64 } from "@/utils/utils";
@@ -25,7 +26,7 @@ interface MessageReactionsProps {
} }
function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) { function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
const { user } = useAppState(); const { user } = useUserStore();
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]); const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set()); const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -162,7 +163,8 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
endRect: Rect; endRect: Rect;
} | null>(null); } | null>(null);
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
const { user, setProfileDialog } = useAppState(); const { user } = useUserStore();
const { setProfileDialog } = useProfileStore();
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map()); const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope; const dmEnvelope = message.runtimeData?.dmEnvelope;
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "@/core/types"; import type { Message, Size2D } from "@/core/types";
import { EmojiMenu } from "./EmojiMenu"; import { EmojiMenu } from "./EmojiMenu";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import styles from "@/pages/chat/css/MessageContextMenu.module.scss"; import styles from "@/pages/chat/css/MessageContextMenu.module.scss";
interface MessageContextMenuProps { interface MessageContextMenuProps {
@@ -35,7 +35,7 @@ export function MessageContextMenu({
isOpen, isOpen,
onOpenChange onOpenChange
}: MessageContextMenuProps) { }: MessageContextMenuProps) {
const { user } = useAppState(); const { user } = useUserStore();
// Internal state for closing animation // Internal state for closing animation
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [reactionBarPosition, setReactionBarPosition] = useState<Size2D>({ x: 0, y: 0 }); const [reactionBarPosition, setReactionBarPosition] = useState<Size2D>({ x: 0, y: 0 });
@@ -1,6 +1,9 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { motion, AnimatePresence } from "motion/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 { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
import { ChatMessages } from "./ChatMessages"; import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper"; import { ChatInputWrapper } from "./ChatInputWrapper";
@@ -23,19 +26,20 @@ interface MessagePanelRendererProps {
} }
function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { function ChatHeaderText({ panel }: { panel: MessagePanel | null }) {
const { chat, user } = useAppState(); const { typingUsers, dmTypingUsers } = usePresenceStore();
const { user } = useUserStore();
const otherTypingUsers = useMemo(() => { const otherTypingUsers = useMemo(() => {
return Array return Array
.from(chat.typingUsers.entries()) .from(typingUsers.entries())
.filter(([userId, username]) => userId !== user.currentUser?.id && username) .filter(([userId, username]) => userId !== user.currentUser?.id && username)
.map(([, username]) => username!); .map(([, username]) => username!);
}, [chat.typingUsers, user.currentUser?.id]); }, [typingUsers, user.currentUser?.id]);
let content: ReactNode; let content: ReactNode;
if (panel instanceof DMPanel) { if (panel instanceof DMPanel) {
const recipientId = panel.getRecipientId()!; const recipientId = panel.getRecipientId()!;
const isTyping = chat.dmTypingUsers.get(recipientId); const isTyping = dmTypingUsers.get(recipientId);
content = isTyping ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />; content = isTyping ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
} else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) { } else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) {
@@ -48,7 +52,8 @@ function ChatHeaderText({ panel }: { panel: MessagePanel | null }) {
} }
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, chat, setProfileDialog } = useAppState(); const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching } = useChatStore();
const { setProfileDialog } = useProfileStore();
const messagePanelRef = useRef<HTMLDivElement>(null); const messagePanelRef = useRef<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null); const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -121,30 +126,30 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
// Handle chat switching animation // Handle chat switching animation
useEffect(() => { useEffect(() => {
if (chat.isSwitching && chat.pendingPanel) { if (isSwitching && pendingPanel) {
// Apply pending panel when animation starts // Apply pending panel when animation starts
applyPendingPanel(); applyPendingPanel();
// End switching state after a brief delay to allow animation // End switching state after a brief delay to allow animation
setTimeout(() => { setTimeout(() => {
chat.setIsSwitching(false); setIsSwitching(false);
}, 200); }, 200);
} }
}, [chat.isSwitching, chat.pendingPanel, applyPendingPanel]); }, [isSwitching, pendingPanel, applyPendingPanel, setIsSwitching]);
// Load messages when panel changes and animation is not running // Load messages when panel changes and animation is not running
useEffect(() => { 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) { 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 // Scroll to bottom only when new messages are added
useEffect(() => { useEffect(() => {
if (!panelState || chat.isSwitching) return; if (!panelState || isSwitching) return;
const currentMessageCount = panelState.messages.length; const currentMessageCount = panelState.messages.length;
const previousMessageCount = previousMessageCountRef.current; const previousMessageCount = previousMessageCountRef.current;
@@ -168,7 +173,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
// Update the previous message count // Update the previous message count
previousMessageCountRef.current = currentMessageCount; previousMessageCountRef.current = currentMessageCount;
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching]); }, [panelState?.messages, panelState?.isLoading, isSwitching]);
function handleCallClick() { function handleCallClick() {
if (panel && panelState && panel.isDm()) { 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 ( return (
<div className={styles.chatContainer}> <div className={styles.chatContainer}>
@@ -5,7 +5,7 @@
* @version 1.0.0 * @version 1.0.0
*/ */
import { useAppState } from "@/pages/chat/state"; import { usePresenceStore } from "@/state/presence";
import styles from "@/pages/chat/css/TypingIndicators.module.scss"; import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineIndicatorProps { interface OnlineIndicatorProps {
@@ -14,8 +14,8 @@ interface OnlineIndicatorProps {
} }
export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) { export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) {
const { chat } = useAppState(); const { onlineStatuses } = usePresenceStore();
const status = chat.onlineStatuses.get(userId); const status = onlineStatuses.get(userId);
// Only show indicator when user is online // Only show indicator when user is online
if (!status || !status.online) { if (!status || !status.online) {
@@ -5,7 +5,8 @@
* @version 1.0.0 * @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"; import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineStatusProps { interface OnlineStatusProps {
@@ -14,8 +15,9 @@ interface OnlineStatusProps {
} }
export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) { export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) {
const { chat, user } = useAppState(); const { onlineStatuses } = usePresenceStore();
const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : chat.onlineStatuses.get(userId); const { user } = useUserStore();
const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : onlineStatuses.get(userId);
function formatLastSeen(lastSeen: string): string { function formatLastSeen(lastSeen: string): string {
const date = new Date(lastSeen); const date = new Date(lastSeen);
@@ -1,8 +1,8 @@
import { useAppState } from "@/pages/chat/state"; import { useChatStore } from "@/state/chat";
import { MessagePanelRenderer } from "./MessagePanelRenderer"; import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() { export function RightPanel() {
const { chat } = useAppState(); const { activePanel } = useChatStore();
return <MessagePanelRenderer panel={chat.activePanel} /> return <MessagePanelRenderer panel={activePanel} />
} }
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; 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 useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -9,8 +10,8 @@ import { motion, AnimatePresence } from "motion/react";
import styles from "@/pages/chat/css/callWindow.module.scss"; import styles from "@/pages/chat/css/callWindow.module.scss";
export function CallWindow() { export function CallWindow() {
const { chat, toggleCallMinimize, user } = useAppState(); const { call, toggleCallMinimized } = useCallStore();
const { call } = chat; const { user } = useUserStore();
const { const {
acceptCall, acceptCall,
rejectCall, rejectCall,
@@ -158,7 +159,7 @@ export function CallWindow() {
<div className={styles.callHeader}> <div className={styles.callHeader}>
<div className={styles.windowControls}> <div className={styles.windowControls}>
<MaterialIconButton <MaterialIconButton
onClick={toggleCallMinimize} onClick={toggleCallMinimized}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"} icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className={styles.windowControlBtn} className={styles.windowControlBtn}
/> />
@@ -1,11 +1,10 @@
import { useAppState } from "@/pages/chat/state"; import { useCallStore } from "@/state/call";
import useCall from "@/pages/chat/hooks/useCall"; import useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { MaterialIconButton } from "@/utils/material"; import { MaterialIconButton } from "@/utils/material";
export function MinimizedCallBar() { export function MinimizedCallBar() {
const { chat, toggleCallMinimize } = useAppState(); const { call, toggleCallMinimized } = useCallStore();
const { call } = chat;
const { endCall, toggleMute } = useCall(); const { endCall, toggleMute } = useCall();
function getGradientClass() { function getGradientClass() {
@@ -39,7 +38,7 @@ export function MinimizedCallBar() {
} }
return ( return (
<div className={`minimized-call-bar ${getGradientClass()}`} onClick={toggleCallMinimize}> <div className={`minimized-call-bar ${getGradientClass()}`} onClick={toggleCallMinimized}>
<div className="call-info"> <div className="call-info">
<img src={defaultAvatar} alt="Avatar" className="avatar" /> <img src={defaultAvatar} alt="Avatar" className="avatar" />
<div className="user-details"> <div className="user-details">
@@ -9,7 +9,7 @@ import {
} from "@/core/api/dm"; } from "@/core/api/dm";
import { fetchUserProfileById } from "@/core/api/account/profile"; import { fetchUserProfileById } from "@/core/api/account/profile";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; 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 { formatDMUsername } from "@/pages/chat/hooks/useDM";
import { onlineStatusManager } from "@/core/onlineStatusManager"; import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager"; import { typingManager } from "@/core/typingManager";
@@ -1,5 +1,5 @@
import type { Message, WebSocketMessage } from "@/core/types"; 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 { export interface MessagePanelState {
id: string; id: string;
@@ -1,7 +1,7 @@
import { MessagePanel } from "./MessagePanel"; import { MessagePanel } from "./MessagePanel";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; 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"; import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging";
export class PublicChatPanel extends MessagePanel { export class PublicChatPanel extends MessagePanel {
+2 -2
View File
@@ -1,5 +1,5 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAppState } from "@/pages/chat/state"; import { useUserStore } from "@/state/user";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon } from "@/utils/material"; import { MaterialButton, MaterialIcon } from "@/utils/material";
@@ -18,7 +18,7 @@ function SupportLink({ children }: { children: React.ReactNode }) {
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAppState(); const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen(); const { isMobile } = useDownloadAppScreen();
const isLoggedIn = user.authToken && user.currentUser; const isLoggedIn = user.authToken && user.currentUser;
+123
View File
@@ -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<CallStore>((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
}
}))
}));
+150
View File
@@ -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<Message>) => 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<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
}
export const useChatStore = create<ChatStore>((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<Message>) => 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"
});
}
}));
+41
View File
@@ -0,0 +1,41 @@
import { create } from "zustand";
interface PresenceStore {
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
typingUsers: Map<number, string>; // userId -> username
dmTypingUsers: Map<number, boolean>;
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<PresenceStore>((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
};
})
}));
+14
View File
@@ -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<ProfileStore>((set) => ({
profileDialog: null,
setProfileDialog: (data: ProfileDialogData | null) => set({ profileDialog: data }),
closeProfileDialog: () => set({ profileDialog: null })
}));
+73
View File
@@ -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<number, {online: boolean, lastSeen: string}>;
typingUsers: Map<number, string>; // userId -> username
dmTypingUsers: Map<number, boolean>;
}
export interface UserState {
currentUser: User | null;
authToken: string | null;
isSuspended: boolean;
suspensionReason: string | null;
}
+159
View File
@@ -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<void>;
setSuspended: (reason: string) => void;
}
export const useUserStore = create<UserStore>((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
}
}))
}));