Create new Material components, redesign the chat UI, add security settings

This commit is contained in:
2025-11-01 14:55:36 +03:00
Unverified
parent 5f49b45eed
commit fc11d06570
40 changed files with 917 additions and 618 deletions
+14 -14
View File
@@ -1,32 +1,32 @@
import { useAppState, type ChatTabs } from "@/pages/chat/state";
import { UnifiedChatsList } from "./UnifiedChatsList";
import type { Tabs } from "mdui/components/tabs";
import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material";
export function ChatTabs() {
const { chat, setActiveTab } = useAppState();
return (
<div className="chat-tabs">
<mdui-tabs
<MaterialTabs
value={chat.activeTab}
full-width
onChange={(e) => setActiveTab((e.target as Tabs).value as ChatTabs)}>
<mdui-tab value="chats">
onChange={(e) => setActiveTab(e.target.value as ChatTabs)}>
<MaterialTab value="chats">
Чаты
</mdui-tab>
<mdui-tab value="channels">
</MaterialTab>
<MaterialTab value="channels">
Каналы
</mdui-tab>
<mdui-tab value="contacts">
</MaterialTab>
<MaterialTab value="contacts">
Контакты
</mdui-tab>
</MaterialTab>
<mdui-tab-panel slot="panel" value="chats">
<MaterialTabPanel slot="panel" value="chats">
<UnifiedChatsList />
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
</mdui-tabs>
</MaterialTabPanel>
<MaterialTabPanel slot="panel" value="channels">Скоро будет...</MaterialTabPanel>
<MaterialTabPanel slot="panel" value="contacts">Скоро будет...</MaterialTabPanel>
</MaterialTabs>
</div>
);
}
+9 -13
View File
@@ -4,29 +4,25 @@ import { SettingsDialog } from "./settings/SettingsDialog";
import { UsernameSearch } from "./UsernameSearch";
import { ChatTabs } from "./ChatTabs";
import { ChatHeader } from "./ChatHeader";
import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material";
function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useAppState();
const handleLogout = () => {
logout();
};
return (
<>
<mdui-bottom-app-bar>
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
<MaterialBottomAppBar>
<MaterialIconButton icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
<MaterialIconButton icon="group_add--filled" />
<div style={{ flexGrow: 1 }}></div>
<mdui-button-icon
<MaterialIconButton
icon="logout--filled"
id="logout-btn"
onClick={handleLogout}
title="Выйти"
></mdui-button-icon>
<mdui-fab icon="edit--filled"></mdui-fab>
</mdui-bottom-app-bar>
onClick={logout}
title="Выйти" />
<MaterialFab icon="edit--filled" />
</MaterialBottomAppBar>
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
</>
);
@@ -10,6 +10,7 @@ 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, MaterialList, MaterialListItem } from "@/utils/material";
interface PublicChat {
id: string;
@@ -223,17 +224,15 @@ export function UnifiedChatsList() {
}
if (isLoadingUsers) {
return (
<mdui-circular-progress />
);
return <MaterialCircularProgress />;
}
return (
<mdui-list>
<MaterialList>
{allChats.map((chat) => {
if (chat.type === "public") {
return (
<mdui-list-item
<MaterialListItem
key={`public-${chat.id}`}
headline={chat.name}
onClick={() => handlePublicChatClick(chat.name)}
@@ -255,11 +254,11 @@ export function UnifiedChatsList() {
objectFit: "cover"
}}
/>
</mdui-list-item>
</MaterialListItem>
);
} else {
return (
<mdui-list-item
<MaterialListItem
key={`dm-${chat.id}`}
headline={chat.display_name}
onClick={() => handleDMClick(chat)}
@@ -288,20 +287,20 @@ export function UnifiedChatsList() {
display: "block"
}}
onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar;
e.target.src = defaultAvatar;
}}
/>
<OnlineIndicator userId={chat.id} />
</div>
{chat.unreadCount > 0 && (
<mdui-badge slot="end-icon">
<MaterialBadge slot="end-icon">
{chat.unreadCount}
</mdui-badge>
</MaterialBadge>
)}
</mdui-list-item>
</MaterialListItem>
);
}
})}
</mdui-list>
</MaterialList>
);
}
@@ -7,6 +7,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png";
import SearchBar from "@/core/components/SearchBar";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
interface SearchUser extends User {
publicKey?: string | null;
@@ -121,7 +122,7 @@ export function UsernameSearch() {
isExpanded={isExpanded}
onToggleExpanded={handleToggleExpanded}
leftIcon={isExpanded ? (
<mdui-button-icon
<MaterialIconButton
className="back-button"
onClick={(e) => {
e.stopPropagation();
@@ -134,7 +135,7 @@ export function UsernameSearch() {
>
{isSearching && (
<div className="search-loading">
<mdui-circular-progress value={0}></mdui-circular-progress>
<MaterialCircularProgress />
<span>Поиск...</span>
</div>
)}
@@ -146,9 +147,9 @@ export function UsernameSearch() {
)}
{!isSearching && searchResults.length > 0 && (
<mdui-list>
<MaterialList>
{searchResults.map((searchUser) => (
<mdui-list-item
<MaterialListItem
key={searchUser.id}
headline={searchUser.username}
onClick={() => handleUserClick(searchUser)}
@@ -174,14 +175,14 @@ export function UsernameSearch() {
display: "block"
}}
onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar;
e.target.src = defaultAvatar;
}}
/>
<OnlineIndicator userId={searchUser.id} />
</div>
</mdui-list-item>
</MaterialListItem>
))}
</mdui-list>
</MaterialList>
)}
{!isSearching && searchQuery.length < 2 && (
@@ -1,3 +1,4 @@
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import "./css/cropper-dialog.scss";
export function CropperDialog() {
@@ -6,14 +7,14 @@ export function CropperDialog() {
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
<MaterialIconButton icon="close" id="cropper-close" />
</div>
<div className="cropper-container">
<div id="cropper-area"></div>
</div>
<div className="cropper-actions">
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
<mdui-button id="crop-save">Сохранить</mdui-button>
<MaterialButton id="crop-cancel" variant="outlined">Отмена</MaterialButton>
<MaterialButton id="crop-save">Сохранить</MaterialButton>
</div>
</div>
</mdui-dialog>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "@/core/types";
import { MaterialButton } from "@/utils/material";
interface ImageCropperProps {
onCrop: (croppedImageData: string) => void;
@@ -180,12 +181,12 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
alt="Crop source"
/>
<div className="cropper-actions">
<mdui-button onClick={handleCrop} disabled={!isLoaded}>
<MaterialButton onClick={handleCrop} disabled={!isLoaded}>
Обрезать
</mdui-button>
<mdui-button variant="outlined" onClick={onCancel}>
</MaterialButton>
<MaterialButton variant="outlined" onClick={onCancel}>
Отмена
</mdui-button>
</MaterialButton>
</div>
</div>
);
@@ -0,0 +1,82 @@
import { useState } from "react";
import { StyledDialog } from "@/core/components/StyledDialog";
import type { DialogProps } from "@/core/types";
import { useAppState } from "@/pages/chat/state";
import { changePassword } from "@/core/api/securityApi";
import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material";
export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) {
const { user } = useAppState();
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="cpd-container">
<div className="cpd-titlebar">
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className="cpd-title">Изменить пароль</div>
</div>
<div className="cpd-content">
<form onSubmit={async (e) => {
e.preventDefault();
if (!user.authToken || !user.currentUser?.username) return;
if (!current || !next || next !== confirm) return;
setBusy(true);
try {
await 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="cpd-logout-all">
<MaterialSwitch
name="cpd-logout-all"
checked={logoutAll}
onInput={(e) => setLogoutAll(e.target.checked)} />
<label htmlFor="cpd-logout-all">Выйти на всех устройствах (кроме текущего)</label>
</div>
<div className="cpd-actions">
<MaterialButton type="submit" disabled={busy}>Сохранить</MaterialButton>
</div>
</form>
</div>
</div>
</StyledDialog>
);
}
@@ -5,11 +5,11 @@ import { StyledDialog } from "@/core/components/StyledDialog";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { useAppState } from "@/pages/chat/state";
import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "@/core/api/authApi";
import { changePassword } from "@/core/api/securityApi";
import ChangePasswordDialog from "./ChangePasswordDialog";
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
import { useImmer } from "use-immer";
import { MaterialButton, MaterialIconButton, MaterialList, MaterialListItem, MaterialSwitch } from "@/utils/material";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings");
@@ -18,10 +18,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const user = useAppState(state => state.user);
const logout = useAppState(state => state.logout);
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
const [cpCurrent, setCpCurrent] = useState("");
const [cpNext, setCpNext] = useState("");
const [cpConfirm, setCpConfirm] = useState("");
const [cpLogoutAll, setCpLogoutAll] = useState(true);
const [cpOpen, setCpOpen] = useState(false);
useEffect(() => {
setPushSupported(isSupported());
@@ -79,131 +76,106 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
};
return (
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="settings-dialog">
<div id="settings-dialog-inner">
<div className="header">
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></mdui-button-icon>
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
</div>
<div id="settings-menu">
<mdui-list>
<mdui-list-item
icon="notifications--filled"
rounded
active={activePanel === "notifications-settings"}
onClick={() => handlePanelChange("notifications-settings")}
style={{ cursor: "pointer" }}
>
Уведомления
</mdui-list-item>
<mdui-list-item
icon="security--filled"
rounded
active={activePanel === "security-settings"}
onClick={() => handlePanelChange("security-settings")}
style={{ cursor: "pointer" }}
>
Безопасность
</mdui-list-item>
<mdui-list-item
icon="devices--filled"
rounded
active={activePanel === "devices-settings"}
onClick={() => handlePanelChange("devices-settings")}
style={{ cursor: "pointer" }}
>
Устройства
</mdui-list-item>
<mdui-list-item
icon="info--filled"
rounded
active={activePanel === "about-settings"}
onClick={() => handlePanelChange("about-settings")}
style={{ cursor: "pointer" }}
>
О приложении
</mdui-list-item>
</mdui-list>
<div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3>
{pushSupported && (
<mdui-switch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
>
Push уведомления
</mdui-switch>
)}
</div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<h3>Безопасность</h3>
<form onSubmit={async (e) => {
e.preventDefault();
if (!user.authToken || !user.username) return;
if (!cpCurrent || !cpNext || cpNext !== cpConfirm) return;
try {
await changePassword(user.authToken, user.username, cpCurrent, cpNext, cpLogoutAll);
setCpCurrent("");
setCpNext("");
setCpConfirm("");
} catch (err) {
console.error(err);
}
}}>
<mdui-text-field label="Текущий пароль" type="password" value={cpCurrent} onInput={(e: any) => setCpCurrent(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<mdui-text-field label="Новый пароль" type="password" value={cpNext} onInput={(e: any) => setCpNext(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<mdui-text-field label="Подтвердите пароль" type="password" value={cpConfirm} onInput={(e: any) => setCpConfirm(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<mdui-switch checked={cpLogoutAll} onInput={(e: any) => setCpLogoutAll(e.target.checked)}>Выйти на всех устройствах (кроме текущего)</mdui-switch>
<div style={{ flexGrow: 1 }}></div>
<mdui-button type="submit" variant="tonal">Сохранить</mdui-button>
</div>
</form>
</div>
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
<h3>Язык</h3>
<mdui-select label="Выберите язык" variant="outlined">
<mdui-menu-item value="ru">Русский</mdui-menu-item>
<mdui-menu-item value="en">English</mdui-menu-item>
<mdui-menu-item value="es">Español</mdui-menu-item>
</mdui-select>
</div>
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
<h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value={25}></mdui-linear-progress>
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div>
<div id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}>
<h3>Устройства</h3>
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<mdui-button variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</mdui-button>
<mdui-button variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</mdui-button>
<>
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="settings-dialog">
<div id="settings-dialog-inner">
<div className="header">
<MaterialIconButton icon="close" id="settings-close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className="title">Настройки</div>
</div>
<div id="settings-menu">
<MaterialList>
<MaterialListItem
icon="notifications--filled"
rounded
active={activePanel === "notifications-settings"}
onClick={() => handlePanelChange("notifications-settings")}
style={{ cursor: "pointer" }}
>
Уведомления
</MaterialListItem>
<MaterialListItem
icon="security--filled"
rounded
active={activePanel === "security-settings"}
onClick={() => handlePanelChange("security-settings")}
style={{ cursor: "pointer" }}
>
Безопасность
</MaterialListItem>
<MaterialListItem
icon="devices--filled"
rounded
active={activePanel === "devices-settings"}
onClick={() => handlePanelChange("devices-settings")}
style={{ cursor: "pointer" }}
>
Устройства
</MaterialListItem>
<MaterialListItem
icon="info--filled"
rounded
active={activePanel === "about-settings"}
onClick={() => handlePanelChange("about-settings")}
style={{ cursor: "pointer" }}
>
О приложении
</MaterialListItem>
</MaterialList>
<div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3>
{pushSupported && (
<MaterialSwitch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle(e.target.checked)}>
Push уведомления
</MaterialSwitch>
)}
</div>
<mdui-list>
{devices.map((d) => (
<mdui-list-item key={d.session_id} icon={d.current ? "devices_other--filled" : "devices--filled"} rounded end-icon={!d.current ? "logout--filled" : undefined} onEndIconClick={async () => { if (!user.authToken || d.current) return; await revokeDevice(user.authToken, d.session_id); const list = await listDevices(user.authToken); updateDevices(() => list); }}>
<div slot="headline">{d.browser_name || "Браузер"} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}</div>
<div slot="description">Последняя активность: {d.last_seen || "—"}</div>
</mdui-list-item>
))}
</mdui-list>
</div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
<h3>О приложении</h3>
<p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
<p><span className="product-name">{PRODUCT_NAME}</span></p>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<h3>Безопасность</h3>
<MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton>
</div>
<div id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}>
<h3>Устройства</h3>
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton>
<MaterialButton variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</MaterialButton>
</div>
<MaterialList>
{devices.map((d) => (
<MaterialListItem
key={d.session_id}
icon={d.current ? "devices_other--filled" : "devices--filled"}
rounded
end-icon={!d.current ? "logout--filled" : undefined}
onClick={async () => {
if (!user.authToken || d.current) return;
await revokeDevice(user.authToken, d.session_id);
const list = await listDevices(user.authToken);
updateDevices(() => list);
}}
>
<div slot="headline">{d.device_name || (d.browser_name || "Браузер")} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}</div>
<div slot="description">Последняя активность: {d.last_seen || "—"}</div>
</MaterialListItem>
))}
</MaterialList>
</div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
<h3>О приложении</h3>
<p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
<p><span className="product-name">{PRODUCT_NAME}</span></p>
</div>
</div>
</div>
</div>
</div>
</StyledDialog>
</StyledDialog>
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
</>
);
}