Backend moved to a separate repository

This commit is contained in:
2026-07-14 09:59:03 +03:00
Unverified
parent 1cc5294d52
commit 5f9d9a78d3
340 changed files with 198 additions and 21988 deletions
@@ -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 />
</>
);
}
+46
View File
@@ -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>
</>
);
}