mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Backend moved to a separate repository
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
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 { 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 { user } = useUserStore();
|
||||
const { setProfileDialog } = useProfileStore();
|
||||
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
|
||||
|
||||
function handleProfileClick() {
|
||||
setProfileDialog({
|
||||
userId: user.currentUser?.id,
|
||||
username: profileData?.username || "Пользователь",
|
||||
display_name: profileData?.display_name || "Пользователь",
|
||||
profilePicture: profileData?.profile_picture,
|
||||
bio: profileData?.description,
|
||||
memberSince: user.currentUser?.created_at,
|
||||
online: user.currentUser?.online,
|
||||
isOwnProfile: true
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={styles.chatHeaderLeft} ref={headerRef}>
|
||||
<img src={logoIcon} alt="Logo" className={styles.logo} />
|
||||
<div className={styles.productName}>{PRODUCT_NAME}</div>
|
||||
<div className={styles.profile}>
|
||||
<a href="#" id="profile-open" onClick={handleProfileClick}>
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
id="preview1"
|
||||
onError={() => setProfilePictureUrl(defaultAvatar)} />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<MinimizedCallBar />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { useRef, useState } from "react";
|
||||
import { SettingsDialog } from "./settings/SettingsDialog";
|
||||
import { UsernameSearch } from "./UsernameSearch";
|
||||
import { UnifiedChatsList } from "./UnifiedChatsList";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { MaterialBottomAppBar, MaterialFab, MaterialIconButton, type MDUIBottomAppBar } from "@/utils/material";
|
||||
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 } = useUserStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaterialBottomAppBar ref={bottomAppBarRef}>
|
||||
<MaterialIconButton icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
<MaterialIconButton
|
||||
icon="logout--filled"
|
||||
id="logout-btn"
|
||||
onClick={logout}
|
||||
title="Выйти" />
|
||||
<MaterialFab icon="edit--filled" />
|
||||
</MaterialBottomAppBar>
|
||||
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftPanel() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const bottomAppBarRef = useRef<MDUIBottomAppBar>(null);
|
||||
|
||||
return (
|
||||
<div className={styles.chatList} ref={containerRef}>
|
||||
<ChatHeader headerRef={headerRef} />
|
||||
<div className={styles.searchContainer}>
|
||||
<UsernameSearch containerRef={containerRef} headerRef={headerRef} bottomAppBarRef={bottomAppBarRef} />
|
||||
</div>
|
||||
<UnifiedChatsList />
|
||||
<BottomAppBar bottomAppBarRef={bottomAppBarRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { useChatStore } from "@/state/chat";
|
||||
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
|
||||
import api from "@/core/api";
|
||||
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||
import type { Message, VerificationStatus } from "@/core/types";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { MaterialBadge, MaterialCircularProgress, MaterialIcon, MaterialList, MaterialListItem } from "@/utils/material";
|
||||
import { displayNameForUser, isDeletedPeer } from "@/core/userDisplay";
|
||||
import { DeletedUserAvatar } from "@/core/DeletedUserAvatar";
|
||||
import styles from "@/pages/chat/css/left-panel.module.scss";
|
||||
|
||||
interface PublicChat {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "public";
|
||||
lastMessage?: Message;
|
||||
}
|
||||
|
||||
interface DMConversation {
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
profile_picture?: string;
|
||||
online?: boolean;
|
||||
type: "dm";
|
||||
lastMessage?: string;
|
||||
unreadCount: number;
|
||||
publicKey?: string | null;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
}
|
||||
|
||||
type ChatItem = PublicChat | DMConversation;
|
||||
|
||||
const PUBLIC_CHAT: PublicChat = {
|
||||
id: "general",
|
||||
name: "Общий чат",
|
||||
type: "public"
|
||||
};
|
||||
|
||||
export function UnifiedChatsList() {
|
||||
const { user } = useUserStore();
|
||||
const { switchToPublicChat, switchToDM, activeTab } = useChatStore();
|
||||
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||
const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({});
|
||||
|
||||
const loadLastMessages = useCallback(async () => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
const { messages } = await api.chats.general.fetchMessages(user.authToken, 1);
|
||||
if (messages?.length > 0) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
setLastMessages({ general: lastMessage });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading last messages:", error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "chats") {
|
||||
loadUsers();
|
||||
loadLastMessages();
|
||||
}
|
||||
}, [activeTab, loadUsers, loadLastMessages]);
|
||||
|
||||
const allChats = useMemo<ChatItem[]>(() => {
|
||||
return [
|
||||
...dmUsers.map((user: DMUser) => ({
|
||||
...user,
|
||||
userId: user.id,
|
||||
display_name: displayNameForUser({ ...user, id: user.id }),
|
||||
type: "dm" as const
|
||||
})),
|
||||
{
|
||||
...PUBLIC_CHAT,
|
||||
lastMessage: lastMessages[PUBLIC_CHAT.id]
|
||||
}
|
||||
];
|
||||
}, [lastMessages, dmUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!websocket) return;
|
||||
|
||||
function handleWebSocketMessage(e: MessageEvent) {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type === "newMessage") {
|
||||
const newMessage = msg.data as Message;
|
||||
setLastMessages(prev => ({
|
||||
...prev,
|
||||
[PUBLIC_CHAT.id]: newMessage
|
||||
}));
|
||||
} else if (msg.type === "messageEdited") {
|
||||
const editedMessage = msg.data as Message;
|
||||
setLastMessages(prev => {
|
||||
if (prev[PUBLIC_CHAT.id]?.id === editedMessage.id) {
|
||||
return {
|
||||
...prev,
|
||||
[PUBLIC_CHAT.id]: editedMessage
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else if (msg.type === "messageDeleted") {
|
||||
const deletedMessageId = msg.data?.message_id;
|
||||
setLastMessages(prev => {
|
||||
if (prev[PUBLIC_CHAT.id]?.id === deletedMessageId) {
|
||||
loadLastMessages();
|
||||
return {
|
||||
...prev,
|
||||
[PUBLIC_CHAT.id]: undefined
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle WebSocket message in UnifiedChatsList:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", handleWebSocketMessage);
|
||||
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
||||
}, [loadLastMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
dmUsers.forEach(dmUser => {
|
||||
onlineStatusManager.subscribe(dmUser.id);
|
||||
});
|
||||
|
||||
return () => {
|
||||
dmUsers.forEach(dmUser => {
|
||||
onlineStatusManager.unsubscribe(dmUser.id);
|
||||
});
|
||||
};
|
||||
}, [dmUsers]);
|
||||
|
||||
function formatPublicChatMessage(chatId: string): string {
|
||||
const lastMessage = lastMessages[chatId];
|
||||
if (!lastMessage) return "";
|
||||
|
||||
const isCurrentUser = lastMessage.user_id === user.currentUser?.id;
|
||||
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
|
||||
const maxLength = 50 - prefix.length;
|
||||
const content = lastMessage.content.length > maxLength
|
||||
? lastMessage.content.substring(0, maxLength) + "..."
|
||||
: lastMessage.content;
|
||||
|
||||
return prefix + content;
|
||||
};
|
||||
|
||||
async function handleDMClick(dmConversation: DMConversation) {
|
||||
if (!dmConversation.publicKey) {
|
||||
const authToken = useUserStore.getState().user.authToken;
|
||||
if (!authToken) return;
|
||||
|
||||
const publicKey = await api.chats.dm.fetchUserPublicKey(dmConversation.id, authToken);
|
||||
if (!publicKey) {
|
||||
console.error("Failed to get public key for user:", dmConversation.id);
|
||||
return;
|
||||
}
|
||||
dmConversation.publicKey = publicKey;
|
||||
}
|
||||
|
||||
await switchToDM({
|
||||
userId: dmConversation.id,
|
||||
username: dmConversation.username,
|
||||
publicKey: dmConversation.publicKey,
|
||||
profilePicture: dmConversation.profile_picture,
|
||||
online: dmConversation.online || false
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return <MaterialCircularProgress />;
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return (
|
||||
<MaterialList className={styles.unifiedChatsList}>
|
||||
<MaterialListItem
|
||||
headline="Аккаунт заблокирован"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<MaterialIcon name="block--filled" slot="icon" />
|
||||
</MaterialListItem>
|
||||
</MaterialList>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MaterialList className={styles.unifiedChatsList}>
|
||||
{allChats.map((chat) => {
|
||||
if (chat.type === "public") {
|
||||
const formattedMessage = formatPublicChatMessage(chat.id);
|
||||
return (
|
||||
<MaterialListItem
|
||||
key={`public-${chat.id}`}
|
||||
headline={chat.name}
|
||||
onClick={() => switchToPublicChat(chat.name)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{formattedMessage && (
|
||||
<span slot="description" className={styles.listDescription}>
|
||||
{formattedMessage}
|
||||
</span>
|
||||
)}
|
||||
<img
|
||||
src={defaultAvatar}
|
||||
alt={chat.name}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
/>
|
||||
</MaterialListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const isDeletedDm = isDeletedPeer(chat);
|
||||
const displayName = displayNameForUser({ ...chat, id: chat.id });
|
||||
|
||||
return (
|
||||
<MaterialListItem
|
||||
key={`dm-${chat.id}`}
|
||||
headline={displayName}
|
||||
onClick={() => handleDMClick(chat)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div slot="headline" className="dm-list-headline">
|
||||
{displayName}
|
||||
{!isDeletedDm && (
|
||||
<StatusBadge
|
||||
verificationStatus={chat.verification_status}
|
||||
verified={chat.verified || false}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span slot="description" className={styles.listDescription}>
|
||||
{chat.lastMessage || "Нет сообщений"}
|
||||
</span>
|
||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||
{isDeletedDm ? (
|
||||
<DeletedUserAvatar
|
||||
userId={chat.id}
|
||||
className={styles.deletedUserAvatar}
|
||||
iconClassName={styles.deletedUserAvatarIcon}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={displayName}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isDeletedDm && <OnlineIndicator userId={chat.id} />}
|
||||
</div>
|
||||
{chat.unreadCount > 0 && (
|
||||
<MaterialBadge slot="end-icon">
|
||||
{chat.unreadCount}
|
||||
</MaterialBadge>
|
||||
)}
|
||||
</MaterialListItem>
|
||||
);
|
||||
})}
|
||||
</MaterialList>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { useChatStore } from "@/state/chat";
|
||||
import api from "@/core/api";
|
||||
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||
import type { User } from "@/core/types";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
|
||||
import { OnlineStatus } from "@/pages/chat/ui/right/OnlineStatus";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import SearchBar from "@/core/components/SearchBar";
|
||||
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem, type MDUIBottomAppBar } from "@/utils/material";
|
||||
import styles from "@/pages/chat/css/left-panel.module.scss";
|
||||
|
||||
interface SearchUser extends User {
|
||||
publicKey?: string | null;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
export interface UsernameSearchProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
headerRef?: React.RefObject<HTMLElement | null>;
|
||||
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
|
||||
}
|
||||
|
||||
export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) {
|
||||
const { user } = useUserStore();
|
||||
const { switchToDM, activeDm } = useChatStore();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchUser[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [debounceTimeout, setDebounceTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
const switchingToUserIdRef = useRef<number | null>(null);
|
||||
const previousSearchResultIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (debounceTimeout) {
|
||||
clearTimeout(debounceTimeout);
|
||||
}
|
||||
|
||||
if (searchQuery.length > 1) {
|
||||
setIsSearching(true);
|
||||
const newTimeout = setTimeout(async () => {
|
||||
if (user.authToken) {
|
||||
try {
|
||||
const users = await api.user.search.searchUsers(searchQuery, user.authToken);
|
||||
setSearchResults(users);
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
setDebounceTimeout(newTimeout);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (debounceTimeout) {
|
||||
clearTimeout(debounceTimeout);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, user.authToken]);
|
||||
|
||||
// Subscribe to online status for all search results
|
||||
useEffect(() => {
|
||||
const activeDmUserId = activeDm?.userId;
|
||||
const switchingToUserId = switchingToUserIdRef.current;
|
||||
const currentSearchResultIds = new Set(searchResults.map(u => u.id));
|
||||
const previousSearchResultIds = new Set(previousSearchResultIdsRef.current);
|
||||
|
||||
// Unsubscribe from users that were in previous results but not in current results
|
||||
// (unless they're the active DM or we're switching to them)
|
||||
previousSearchResultIds.forEach(userId => {
|
||||
if (!currentSearchResultIds.has(userId) &&
|
||||
userId !== activeDmUserId &&
|
||||
userId !== switchingToUserId) {
|
||||
onlineStatusManager.unsubscribe(userId);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to all current search results
|
||||
searchResults.forEach(searchUser => {
|
||||
onlineStatusManager.subscribe(searchUser.id);
|
||||
});
|
||||
|
||||
// Update previous results for next effect run
|
||||
previousSearchResultIdsRef.current = currentSearchResultIds;
|
||||
|
||||
// Cleanup function - don't unsubscribe here as normal transitions are handled in effect body
|
||||
// This only runs when component unmounts or when transitioning to empty results
|
||||
return () => {
|
||||
// Note: Normal search result transitions are handled above in the effect body
|
||||
// by comparing previous vs current. This cleanup only runs when the component
|
||||
// unmounts or when the dependency changes, but we've already handled
|
||||
// unsubscription in the effect body above, so this is mostly a no-op for normal transitions.
|
||||
|
||||
// Clear the ref if the user is now the active DM (state has updated)
|
||||
const finalSwitchingToUserId = switchingToUserIdRef.current;
|
||||
const finalActiveDmUserId = activeDm?.userId;
|
||||
if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) {
|
||||
switchingToUserIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [searchResults, activeDm?.userId]);
|
||||
|
||||
|
||||
async function handleUserClick(searchUser: SearchUser) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
|
||||
let publicKey = searchUser.publicKey;
|
||||
if (!publicKey) {
|
||||
const fetchedPublicKey = await api.chats.dm.fetchUserPublicKey(searchUser.id, user.authToken);
|
||||
publicKey = fetchedPublicKey;
|
||||
}
|
||||
|
||||
if (publicKey) {
|
||||
// Store the userId we're switching to so cleanup doesn't unsubscribe
|
||||
switchingToUserIdRef.current = searchUser.id;
|
||||
switchToDM({
|
||||
userId: searchUser.id,
|
||||
username: searchUser.username,
|
||||
publicKey: publicKey,
|
||||
profilePicture: searchUser.profile_picture,
|
||||
online: searchUser.online || false
|
||||
});
|
||||
// Collapse search
|
||||
setIsExpanded(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start DM conversation:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleQueryChange(query: string) {
|
||||
setSearchQuery(query);
|
||||
}
|
||||
|
||||
function handleToggleExpanded() {
|
||||
if (isExpanded) {
|
||||
// Collapsing
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchBar
|
||||
placeholder="Поиск"
|
||||
searchQuery={searchQuery}
|
||||
onQueryChange={handleQueryChange}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
leftIcon={isExpanded ? (
|
||||
<MaterialIconButton
|
||||
className="back-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleExpanded();
|
||||
}}
|
||||
type="button"
|
||||
icon="arrow_back--outlined"
|
||||
/>
|
||||
) : "search--outlined"}
|
||||
containerRef={containerRef}
|
||||
headerRef={headerRef}
|
||||
bottomAppBarRef={bottomAppBarRef}
|
||||
>
|
||||
{isSearching && (
|
||||
<div className={styles.searchLoading}>
|
||||
<MaterialCircularProgress />
|
||||
<span>Поиск...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && searchQuery.length >= 2 && searchResults.length === 0 && (
|
||||
<div className={styles.searchEmpty}>
|
||||
<span>Пользователи не найдены</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && searchResults.length > 0 && (
|
||||
<MaterialList>
|
||||
{searchResults.map((searchUser) => (
|
||||
<MaterialListItem
|
||||
key={searchUser.id}
|
||||
headline={searchUser.username}
|
||||
onClick={() => handleUserClick(searchUser)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div slot="custom" className={styles.searchResultContainer}>
|
||||
<div className={styles.searchResult}>
|
||||
<div className={styles.searchResultIcon}>
|
||||
<img
|
||||
src={searchUser.profile_picture || defaultAvatar}
|
||||
alt={searchUser.username}
|
||||
className={styles.searchResultIconImg}
|
||||
onError={(e) => {
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={searchUser.id} />
|
||||
</div>
|
||||
<div className={styles.searchResultBody}>
|
||||
<div className={styles.searchResultHeadline}>
|
||||
{searchUser.username}
|
||||
<StatusBadge
|
||||
verificationStatus={searchUser.verification_status}
|
||||
verified={searchUser.verified || false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.searchResultDescription}>
|
||||
<OnlineStatus userId={searchUser.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialListItem>
|
||||
))}
|
||||
</MaterialList>
|
||||
)}
|
||||
|
||||
{!isSearching && searchQuery.length < 2 && (
|
||||
<div className={styles.searchHint}>
|
||||
<span>Введите минимум 2 символа для поиска</span>
|
||||
</div>
|
||||
)}
|
||||
</SearchBar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MaterialList, MaterialListItem } from "@/utils/material";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import api from "@/core/api";
|
||||
import { confirm } from "mdui/functions/confirm";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
interface AccountPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AccountPanel({ onClose }: AccountPanelProps) {
|
||||
const { user, logout } = useUserStore();
|
||||
const authToken = user?.authToken;
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (!authToken) return;
|
||||
|
||||
try {
|
||||
await confirm({
|
||||
headline: "Удалить аккаунт?",
|
||||
description: "Профиль будет удалён без возможности восстановления, логин освободится. Отправленные сообщения могут остаться в чатах.",
|
||||
confirmText: "Удалить",
|
||||
cancelText: "Отмена"
|
||||
});
|
||||
|
||||
await api.user.auth.deleteAccount(authToken);
|
||||
logout();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (error !== "cancelled") {
|
||||
console.error("Failed to delete account:", error);
|
||||
alert(error instanceof Error ? error.message : "Failed to delete account");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Account</h3>
|
||||
<MaterialList>
|
||||
<MaterialListItem
|
||||
onClick={logout}
|
||||
className={styles.clickableItem}
|
||||
headline="Logout"
|
||||
description="Sign out of your account"
|
||||
icon="logout"
|
||||
/>
|
||||
<MaterialListItem
|
||||
onClick={handleDeleteAccount}
|
||||
className={`${styles.clickableItem} ${styles.dangerItem}`}
|
||||
headline="Delete Account"
|
||||
description="Permanently delete your account"
|
||||
icon="delete_forever"
|
||||
/>
|
||||
</MaterialList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { StyledDialog } from "@/core/components/StyledDialog";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import api from "@/core/api";
|
||||
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 } = useUserStore();
|
||||
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [logoutAll, setLogoutAll] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
return (
|
||||
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="change-password-dialog">
|
||||
<div className={styles.cpdContainer}>
|
||||
<div className={styles.cpdTitlebar}>
|
||||
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
|
||||
<div className={styles.cpdTitle}>Изменить пароль</div>
|
||||
</div>
|
||||
<div className={styles.cpdContent}>
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!user.authToken || !user.currentUser?.username) return;
|
||||
if (!current || !next || next !== confirm) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.user.auth.changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setConfirm("");
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
name="cpd-current-password"
|
||||
label="Текущий пароль"
|
||||
type="password"
|
||||
value={current}
|
||||
onInput={(e) => setCurrent(e.target.value)}
|
||||
variant="outlined"
|
||||
toggle-password
|
||||
required />
|
||||
<MaterialTextField
|
||||
name="cpd-new-password"
|
||||
label="Новый пароль"
|
||||
type="password"
|
||||
value={next}
|
||||
onInput={(e) => setNext(e.target.value)}
|
||||
variant="outlined"
|
||||
toggle-password
|
||||
required />
|
||||
<MaterialTextField
|
||||
name="cpd-confirm-password"
|
||||
label="Подтвердите пароль"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onInput={(e) => setConfirm(e.target.value)}
|
||||
variant="outlined"
|
||||
toggle-password
|
||||
required />
|
||||
<div className={styles.cpdLogoutAll}>
|
||||
<MaterialSwitch
|
||||
name="cpd-logout-all"
|
||||
checked={logoutAll}
|
||||
onInput={(e) => setLogoutAll(e.target.checked)} />
|
||||
<label htmlFor="cpd-logout-all">Выйти на всех устройствах (кроме текущего)</label>
|
||||
</div>
|
||||
<div className={styles.cpdActions}>
|
||||
<MaterialButton type="submit" disabled={busy}>Сохранить</MaterialButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</StyledDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useImmer } from "use-immer";
|
||||
import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import api from "@/core/api";
|
||||
import type { DeviceInfo } from "@/core/api/user/devices";
|
||||
import { confirm } from "mdui/functions/confirm";
|
||||
import { parseApiTimestamp } from "@/utils/utils";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
export function DevicesPanel() {
|
||||
const { user } = useUserStore();
|
||||
const authToken = user?.authToken ?? null;
|
||||
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
|
||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||
const [revokingDevices, setRevokingDevices] = useImmer<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (authToken) {
|
||||
loadDevices();
|
||||
}
|
||||
}, [authToken]);
|
||||
|
||||
async function loadDevices() {
|
||||
if (!authToken) return;
|
||||
|
||||
setDevicesLoading(true);
|
||||
try {
|
||||
const deviceList = await api.user.devices.list(authToken);
|
||||
updateDevices(deviceList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load devices:", error);
|
||||
} finally {
|
||||
setDevicesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeDevice(sessionId: string) {
|
||||
if (!authToken) return;
|
||||
|
||||
try {
|
||||
await confirm({
|
||||
headline: "Revoke Device?",
|
||||
description: "This will log out this device. You will need to log in again on this device.",
|
||||
confirmText: "Revoke",
|
||||
cancelText: "Cancel"
|
||||
});
|
||||
|
||||
setRevokingDevices(draft => {
|
||||
draft.add(sessionId);
|
||||
});
|
||||
|
||||
await api.user.devices.revoke(authToken, sessionId);
|
||||
await loadDevices();
|
||||
} catch (error) {
|
||||
if (error !== "cancelled") {
|
||||
console.error("Failed to revoke device:", error);
|
||||
}
|
||||
} finally {
|
||||
setRevokingDevices(draft => {
|
||||
draft.delete(sessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoutAll() {
|
||||
if (!authToken) return;
|
||||
|
||||
try {
|
||||
await confirm({
|
||||
headline: "Logout All Other Devices?",
|
||||
description: "This will log you out on all other devices. You will remain logged in on this device.",
|
||||
confirmText: "Logout All",
|
||||
cancelText: "Cancel"
|
||||
});
|
||||
|
||||
await api.user.devices.revokeAll(authToken);
|
||||
await loadDevices();
|
||||
} catch (error) {
|
||||
if (error !== "cancelled") {
|
||||
console.error("Failed to logout all devices:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDeviceInfo(device: DeviceInfo): string {
|
||||
const parts: string[] = [];
|
||||
if (device.device_name) parts.push(device.device_name);
|
||||
if (device.os_name) parts.push(device.os_name);
|
||||
if (device.browser_name) parts.push(device.browser_name);
|
||||
return parts.length > 0 ? parts.join(" • ") : device.device_type || "Unknown device";
|
||||
}
|
||||
|
||||
function formatLastSeen(dateStr: string | undefined): string {
|
||||
if (!dateStr) return "Never";
|
||||
const date = parseApiTimestamp(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
if (devicesLoading) {
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Devices</h3>
|
||||
<div className={styles.loadingContainer}>
|
||||
<MaterialCircularProgress />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Devices</h3>
|
||||
<MaterialList>
|
||||
{devices.map((device) => (
|
||||
<MaterialListItem
|
||||
key={device.session_id}
|
||||
className={styles.clickableItem}
|
||||
headline={formatDeviceInfo(device)}
|
||||
description={device.current ? "Current" : "Last seen: " + formatLastSeen(device.last_seen)}
|
||||
icon={device.current ? "smartphone" : "phone_android"}
|
||||
onClick={() => handleRevokeDevice(device.session_id)}
|
||||
disabled={revokingDevices.has(device.session_id)}
|
||||
/>
|
||||
))}
|
||||
</MaterialList>
|
||||
{devices.filter(d => !d.current).length > 0 && (
|
||||
<div className={styles.sectionActions}>
|
||||
<MaterialButton
|
||||
onClick={handleLogoutAll}
|
||||
variant="tonal"
|
||||
>
|
||||
Logout All Other Devices
|
||||
</MaterialButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from "@/utils/material";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import api from "@/core/api";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
export function NotificationsPanel() {
|
||||
const { user } = useUserStore();
|
||||
const authToken = user?.authToken ?? null;
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const switchRef = useRef<MDUISwitch>(null);
|
||||
|
||||
async function checkPushStatus() {
|
||||
if (!isSupported()) {
|
||||
setPushEnabled(false);
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
try {
|
||||
let permission: string;
|
||||
if (isElectron) {
|
||||
permission = await window.electronInterface.notifications.requestPermission();
|
||||
} else {
|
||||
permission = Notification.permission;
|
||||
}
|
||||
console.log("checkPushStatus", permission);
|
||||
setPushEnabled(permission === "granted");
|
||||
} catch (error) {
|
||||
console.error("Failed to check push status:", error);
|
||||
setPushEnabled(false);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePushToggle(enabled: boolean) {
|
||||
console.log("handlePushToggle", enabled);
|
||||
if (!authToken || !isSupported() || loading) return;
|
||||
|
||||
// Optimistic update
|
||||
const previousState = pushEnabled;
|
||||
setPushEnabled(enabled);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
// Initialize push notifications (creates service worker and requests permission)
|
||||
const initResult = await initialize();
|
||||
if (!initResult) {
|
||||
throw new Error("Failed to initialize push notifications");
|
||||
}
|
||||
|
||||
// Subscribe to push notifications (sends subscription to server)
|
||||
// The subscribe() function will handle creating/getting the subscription if needed
|
||||
const subscribeResult = await subscribe(authToken);
|
||||
if (!subscribeResult) {
|
||||
throw new Error("Failed to subscribe to push notifications");
|
||||
}
|
||||
|
||||
// Verify the state after subscription - check permission to ensure it's actually granted
|
||||
await checkPushStatus();
|
||||
} else {
|
||||
// Unsubscribe locally first
|
||||
const unsubscribed = await unsubscribe();
|
||||
if (!unsubscribed) {
|
||||
throw new Error("Failed to unsubscribe locally");
|
||||
}
|
||||
|
||||
// Then unsubscribe from server
|
||||
await api.push.subscription.unsubscribe(authToken);
|
||||
|
||||
// After unsubscribing, permission is still granted but we're not subscribed
|
||||
// So we keep the state as disabled (false)
|
||||
setPushEnabled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle push notifications:", error);
|
||||
// Revert optimistic update
|
||||
setPushEnabled(previousState);
|
||||
// Re-check actual status to sync with reality
|
||||
await checkPushStatus();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleListItemClick(e: React.MouseEvent) {
|
||||
if (checking || loading || !isSupported() || e.target === switchRef.current) return;
|
||||
handlePushToggle(!pushEnabled);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Notifications</h3>
|
||||
<MaterialList>
|
||||
<MaterialListItem
|
||||
className={styles.clickableItem}
|
||||
headline="Push Notifications"
|
||||
description="Receive notifications for new messages"
|
||||
icon="notifications"
|
||||
onClick={handleListItemClick}>
|
||||
<MaterialSwitch
|
||||
checked={pushEnabled}
|
||||
disabled={!isSupported() || loading || checking}
|
||||
onChange={(e) => handlePushToggle(e.target.checked)}
|
||||
slot="end-icon"
|
||||
ref={switchRef}
|
||||
/>
|
||||
</MaterialListItem>
|
||||
</MaterialList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { MaterialList, MaterialListItem } from "@/utils/material";
|
||||
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
export function SecurityPanel() {
|
||||
const [cpOpen, setCpOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className={styles.panelTitle}>Security</h3>
|
||||
<MaterialList>
|
||||
<MaterialListItem
|
||||
onClick={() => setCpOpen(true)}
|
||||
className={styles.clickableItem}
|
||||
headline="Change Password"
|
||||
description="Change your account password"
|
||||
icon="password"
|
||||
/>
|
||||
</MaterialList>
|
||||
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import { StyledDialog } from "@/core/components/StyledDialog";
|
||||
import { NotificationsPanel } from "./NotificationsPanel";
|
||||
import { DevicesPanel } from "./DevicesPanel";
|
||||
import { SecurityPanel } from "./SecurityPanel";
|
||||
import { AccountPanel } from "./AccountPanel";
|
||||
import { MaterialList, MaterialListItem, MaterialIconButton } from "@/utils/material";
|
||||
import styles from "@/pages/chat/css/settings-dialog.module.scss";
|
||||
|
||||
interface SettingsSection {
|
||||
title: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const sections: SettingsSection[] = [
|
||||
{
|
||||
title: "Notifications",
|
||||
icon: "notifications",
|
||||
component: <NotificationsPanel />
|
||||
},
|
||||
{
|
||||
title: "Devices",
|
||||
icon: "devices",
|
||||
component: <DevicesPanel />
|
||||
},
|
||||
{
|
||||
title: "Security",
|
||||
icon: "lock",
|
||||
component: <SecurityPanel />
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
icon: "account_circle",
|
||||
component: <AccountPanel onClose={() => onOpenChange(false)} />
|
||||
}
|
||||
];
|
||||
|
||||
const [activeSection, setActiveSection] = useState<number>(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className={styles.settingsDialog}>
|
||||
<div className={styles.settingsDialogInner}>
|
||||
<div className={styles.settingsHeader}>
|
||||
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)} />
|
||||
<h2 className={styles.settingsTitle}>Settings</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.settingsLayout}>
|
||||
<div className={styles.sidebar}>
|
||||
<MaterialList>
|
||||
{sections.map((section, index) => (
|
||||
<MaterialListItem
|
||||
key={index}
|
||||
onClick={() => setActiveSection(index)}
|
||||
active={activeSection === index}
|
||||
rounded
|
||||
headline={section.title}
|
||||
icon={section.icon}
|
||||
/>
|
||||
))}
|
||||
</MaterialList>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentPanel}>
|
||||
<AnimatePresence mode="wait">
|
||||
{sections.map((section, index) => (
|
||||
activeSection === index && (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={styles.panelContent}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
{section.component}
|
||||
</motion.div>
|
||||
)
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user