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
+5 -3
View File
@@ -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<string | null>(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;
+9 -7
View File
@@ -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<ProfileDialogData | null>(null);
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(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;
@@ -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<HTMLElement | null> }) {
const { profileData } = useProfile();
const { setProfileDialog, user } = useAppState();
const { user } = useUserStore();
const { setProfileDialog } = useProfileStore();
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
function handleProfileClick() {
@@ -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<MDUIBottomAppBar | null> }) {
const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useAppState();
const { logout } = useUserStore();
return (
<>
@@ -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<Record<string, Message | undefined>>({});
@@ -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<ChatItem[]>(() => {
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);
@@ -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<SearchUser[]>([]);
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) {
@@ -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() {
@@ -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("");
@@ -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<DeviceInfo[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
@@ -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);
@@ -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 (
<div className="chat-header">
@@ -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<ContextMenuState>({
+5 -3
View File
@@ -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<Reaction[]>([]);
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(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<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope;
@@ -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<Size2D>({ x: 0, y: 0 });
@@ -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 ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
} 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<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div className={styles.chatContainer}>
@@ -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) {
@@ -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);
@@ -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 <MessagePanelRenderer panel={chat.activePanel} />
return <MessagePanelRenderer panel={activePanel} />
}
@@ -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() {
<div className={styles.callHeader}>
<div className={styles.windowControls}>
<MaterialIconButton
onClick={toggleCallMinimize}
onClick={toggleCallMinimized}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
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 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 (
<div className={`minimized-call-bar ${getGradientClass()}`} onClick={toggleCallMinimize}>
<div className={`minimized-call-bar ${getGradientClass()}`} onClick={toggleCallMinimized}>
<div className="call-info">
<img src={defaultAvatar} alt="Avatar" className="avatar" />
<div className="user-details">
@@ -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";
@@ -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;
@@ -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 {