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
+12 -11
View File
@@ -12,6 +12,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineStatus } from "./right/OnlineStatus";
import { Input } from "@/core/components/Input";
import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialButton, MaterialFab, MaterialIcon } from "@/utils/material";
interface SectionProps {
type: string;
@@ -55,7 +56,7 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
return (
<div className={`section ${type} ${error ? 'error' : ''}`}>
<mdui-icon name={icon} />
<MaterialIcon name={icon} />
<div className="content-container">
<label className="label">{label}</label>
{valueComponent}
@@ -442,12 +443,11 @@ export function ProfileDialog() {
className="profile-dialog"
afterChildren={
currentData.isOwnProfile && (
<mdui-fab
<MaterialFab
icon="check"
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
onClick={handleSave}
disabled={isSaving}
/>
disabled={isSaving} />
)
}
>
@@ -457,16 +457,16 @@ export function ProfileDialog() {
src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
e.target.src = defaultAvatar;
}}
/>
{currentData.isOwnProfile && (
<div
className="profile-picture-edit-overlay"
onClick={handleProfilePictureClick}
>
<mdui-icon name="camera_alt--filled" />
<MaterialIcon name="camera_alt--filled" />
</div>
)}
</div>
@@ -481,6 +481,7 @@ export function ProfileDialog() {
onChange={handleDisplayNameChange}
readOnly={!currentData.isOwnProfile}
placeholder="Имя" />
<StatusBadge
verified={currentData.verified || false}
userId={currentData.userId}
@@ -502,22 +503,22 @@ export function ProfileDialog() {
<div className="admin-actions-section">
<h3 className="admin-actions-header">Admin Actions</h3>
<div className="admin-buttons">
<mdui-button
<MaterialButton
variant="filled"
color="error"
icon={currentData.suspended ? "check_circle--filled" : "block--filled"}
onClick={handleSuspend}
>
{currentData.suspended ? "Unsuspend Account" : "Suspend Account"}
</mdui-button>
<mdui-button
</MaterialButton>
<MaterialButton
variant="filled"
color="error"
icon="delete_forever--filled"
onClick={handleDelete}
>
Delete Account
</mdui-button>
</MaterialButton>
<VerifyButton
userId={currentData.userId!}
verified={currentData.verified || false}
@@ -1,4 +1,5 @@
import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialIcon } from "@/utils/material";
interface SuspensionDialogProps {
reason: string;
@@ -13,7 +14,7 @@ export function SuspensionDialog({ reason, open, onOpenChange }: SuspensionDialo
onOpenChange={onOpenChange}>
<div className="suspension-dialog-content">
<div className="suspension-icon-section">
<mdui-icon name="block--filled" className="suspension-icon" />
<MaterialIcon name="block--filled" className="suspension-icon" />
</div>
<div className="suspension-text">
+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} />
</>
);
}
@@ -6,6 +6,7 @@ import type { Message } from "@/core/types";
import Quote from "@/core/components/Quote";
import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu";
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void;
@@ -155,12 +156,12 @@ export function ChatInputWrapper(
style={{ overflow: "hidden" }}
>
<div className="reply-preview contextual-preview">
<mdui-icon name="edit" />
<MaterialIcon name="edit" />
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{editingMessage!.username}</span>
<span className="reply-text">{editingMessage!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearEdit}></MaterialIconButton>
</div>
</motion.div>
)}
@@ -175,12 +176,12 @@ export function ChatInputWrapper(
style={{ overflow: "hidden" }}
>
<div className="reply-preview contextual-preview">
<mdui-icon name="reply" />
<MaterialIcon name="reply" />
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearReply}></MaterialIconButton>
</div>
</motion.div>
)}
@@ -195,7 +196,7 @@ export function ChatInputWrapper(
style={{ overflow: "hidden" }}
>
<div className="attachments-preview contextual-preview">
<mdui-icon name="attach_file" />
<MaterialIcon name="attach_file" />
<div className="attachments-chips">
{selectedFiles.map((file, i) => (
<mdui-chip
@@ -211,19 +212,19 @@ export function ChatInputWrapper(
}
}}
>
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
<MaterialIcon slot="icon" name="attach_file"></MaterialIcon>
<span className="name">{file.name}</span>
</mdui-chip>
))}
</div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
<MaterialIconButton icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="chat-input">
<div className="left-buttons">
<mdui-button-icon
<MaterialIconButton
icon="mood"
onClick={handleEmojiButtonClick}
onMouseDown={e => e.stopPropagation()}
@@ -240,7 +241,7 @@ export function ChatInputWrapper(
onTextChange={handleMessageChange}
onEnter={handleSubmit} />
<div className="buttons">
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
<MaterialIconButton icon="attach_file" onClick={handleAttachClick} className="attach-btn"></MaterialIconButton>
<button type="submit" className="send-btn">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button>
@@ -250,7 +251,7 @@ export function ChatInputWrapper(
<MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc>
<div slot="headline">Ошибка</div>
<div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
<MaterialButton slot="action" onClick={() => setErrorOpen(false)}>Закрыть</MaterialButton>
</MaterialDialog>
<EmojiMenu
@@ -6,6 +6,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { MaterialDialog } from "@/core/components/Dialog";
import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
import { MaterialButton } from "@/utils/material";
interface ChatMessagesProps {
messages?: MessageType[];
@@ -147,8 +148,8 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
headline="Удалить сообщение?"
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}>
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button>
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button>
<MaterialButton slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</MaterialButton>
<MaterialButton slot="action" variant="filled" onClick={confirmDelete}>Удалить</MaterialButton>
</MaterialDialog>
{/* Context Menu */}
+16 -17
View File
@@ -16,6 +16,7 @@ import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
import { parseProfileLink } from "@/core/profileLinks";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
interface MessageReactionsProps {
reactions?: Reaction[];
@@ -415,10 +416,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}
async function handleLinkClick(e: React.MouseEvent<HTMLDivElement>) {
const target = e.target as HTMLElement;
if (target.tagName === 'A') {
const profileLink = parseProfileLink((target as HTMLAnchorElement).href);
if (e.target.tagName === 'A') {
const link = (e.target as unknown as HTMLAnchorElement).href;
const profileLink = parseProfileLink(link);
if (profileLink) {
e.preventDefault();
@@ -443,7 +443,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
isOwnProfile: userProfile.id === user.currentUser?.id
});
} else {
throw new Error("Invalid link: " + (target as HTMLAnchorElement).href);
throw new Error(`Invalid link: ${link}`);
}
} catch (error) {
console.error("Failed to fetch user profile from link:", error);
@@ -483,8 +483,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
alt={message.username}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
e.target.src = defaultAvatar;
}}
/>
</div>
@@ -517,7 +516,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
onClick={handleLinkClick} />
{message.files && message.files.length > 0 && (
<mdui-list className="message-attachments">
<MaterialList className="message-attachments">
{message.files.map((file, idx) => {
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
const isEncryptedDm = Boolean(isDm && file.encrypted);
@@ -542,7 +541,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
/>
{(!loadedImages.has(file.path) || isSending) && (
<div className="loading-overlay">
<mdui-circular-progress />
<MaterialCircularProgress />
</div>
)}
</div>
@@ -554,18 +553,18 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
await downloadFile(file);
}}
>
<mdui-list-item>
<MaterialListItem>
<span className="with-icon-gap">
{isDownloading ? <mdui-circular-progress /> : null}
{isDownloading ? <MaterialCircularProgress /> : null}
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</span>
</mdui-list-item>
</MaterialListItem>
</a>
)}
</div>
);
})}
</mdui-list>
</MaterialList>
)}
<Reactions
@@ -585,7 +584,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator">
{message.runtimeData.sendingState.status === 'sending' && (
<mdui-circular-progress style={{ width: '16px', height: '16px' }} />
<MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
)}
{message.runtimeData.sendingState.status === 'failed' && (
<span className="material-symbols error-icon">error</span>
@@ -617,13 +616,13 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
onClick={e => e.stopPropagation()}
/>
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
<mdui-button-icon icon="close" onClick={closeFullscreen} />
<MaterialIconButton icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? (
<div className="progress-wrapper">
<mdui-circular-progress />
<MaterialCircularProgress />
</div>
) : (
<mdui-button-icon icon="download" onClick={downloadImage} />
<MaterialIconButton icon="download" onClick={downloadImage} />
)}
</div>
</div>,
@@ -14,6 +14,7 @@ import { TypingIndicator } from "./TypingIndicator";
import { OnlineStatus } from "./OnlineStatus";
import { typingManager } from "@/core/typingManager";
import { PublicChatPanel } from "./panels/PublicChatPanel";
import { MaterialIcon, MaterialIconButton } from "@/utils/material";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
@@ -199,198 +200,202 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<AnimatePresence mode="wait">
<motion.div
key={panelKey}
ref={messagePanelRef}
className="chat-main"
id="chat-inner"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current += 1;
// Only show overlay when actual files are dragged
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
if (hasFiles) setIsDragging(true);
} : undefined}
onDragOver={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
} : undefined}
onDragLeave={panel ? (e) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) setIsDragging(false);
} : undefined}
onDrop={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files || []);
if (files.length > 0 && addFilesRef.current) {
addFilesRef.current(files);
}
setIsDragging(false);
dragCounterRef.current = 0;
} : undefined}>
<div className="chat-header">
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<ChatHeaderText panel={panel} />
className="chat-wrapper"
>
<div
ref={messagePanelRef}
className="chat-main"
id="chat-inner"
onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current += 1;
// Only show overlay when actual files are dragged
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
if (hasFiles) setIsDragging(true);
} : undefined}
onDragOver={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
} : undefined}
onDragLeave={panel ? (e) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) setIsDragging(false);
} : undefined}
onDrop={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files || []);
if (files.length > 0 && addFilesRef.current) {
addFilesRef.current(files);
}
setIsDragging(false);
dragCounterRef.current = 0;
} : undefined}>
<div className="chat-header">
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<ChatHeaderText panel={panel} />
</div>
{panel?.isDm() && (
<MaterialIconButton onClick={handleCallClick} icon="call--filled" />
)}
</div>
</div>
{panel?.isDm() && (
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
{panelState?.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка сообщений...
</div>
</div>
) : panelState && panel ? (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
) : (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите чат на боковой панели, чтобы начать переписку
</div>
</div>
)}
{panel && (
<>
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
<MaterialIcon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<ChatInputWrapper
onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null);
}}
onSaveEdit={(content) => {
if (editMessage) {
panel.handleEditMessage(editMessage.id, content);
setEditMessage(null);
}
}}
replyTo={replyTo}
replyToVisible={replyToVisible}
onClearReply={() => {
setPendingAction(null);
setReplyToVisible(false);
}}
onCloseReply={() => {
setReplyTo(null);
if (pendingAction && pendingAction.type === "edit") {
setEditMessage(pendingAction.message);
setPendingAction(null);
}
}}
editingMessage={editMessage}
editVisible={editVisible}
onClearEdit={() => {
setPendingAction(null);
setEditVisible(false);
}}
onCloseEdit={() => {
setEditMessage(null);
if (pendingAction && pendingAction.type === "reply") {
setReplyTo(pendingAction.message);
setPendingAction(null);
}
}}
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
messagePanelRef={messagePanelRef}
onTyping={() => {
if (panel.isDm()) {
const dmPanel = panel as DMPanel;
dmPanel.handleTyping();
} else {
typingManager.sendTyping();
}
}}
onStopTyping={() => {
if (panel.isDm()) {
const dmPanel = panel as DMPanel;
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
} else {
typingManager.stopTypingOnMessage();
}
}}
/>
</>
)}
</div>
</div>
{panelState?.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка сообщений...
</div>
</div>
) : panelState && panel ? (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
) : (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите чат на боковой панели, чтобы начать переписку
</div>
</div>
)}
{panel && (
<>
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
<mdui-icon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<ChatInputWrapper
onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null);
}}
onSaveEdit={(content) => {
if (editMessage) {
panel.handleEditMessage(editMessage.id, content);
setEditMessage(null);
}
}}
replyTo={replyTo}
replyToVisible={replyToVisible}
onClearReply={() => {
setPendingAction(null);
setReplyToVisible(false);
}}
onCloseReply={() => {
setReplyTo(null);
if (pendingAction && pendingAction.type === "edit") {
setEditMessage(pendingAction.message);
setPendingAction(null);
}
}}
editingMessage={editMessage}
editVisible={editVisible}
onClearEdit={() => {
setPendingAction(null);
setEditVisible(false);
}}
onCloseEdit={() => {
setEditMessage(null);
if (pendingAction && pendingAction.type === "reply") {
setReplyTo(pendingAction.message);
setPendingAction(null);
}
}}
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
messagePanelRef={messagePanelRef}
onTyping={() => {
if (panel.isDm()) {
const dmPanel = panel as DMPanel;
dmPanel.handleTyping();
} else {
typingManager.sendTyping();
}
}}
onStopTyping={() => {
if (panel.isDm()) {
const dmPanel = panel as DMPanel;
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
} else {
typingManager.stopTypingOnMessage();
}
}}
/>
</>
)}
</motion.div>
</AnimatePresence>
@@ -4,6 +4,7 @@ import useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png";
import { createPortal } from "react-dom";
import { id } from "@/utils/utils";
import { MaterialIconButton } from "@/utils/material";
export function CallWindow() {
const { chat, toggleCallMinimize, user } = useAppState();
@@ -196,8 +197,8 @@ export function CallWindow() {
onMouseDown={(e) => {
if (call.isMinimized) {
// Only start dragging if not clicking on a button
const target = e.target as HTMLElement;
if (!target.closest("mdui-button-icon")) {
// TODO change it to e.stopPropagation() on the buttons
if (!e.target.closest("mdui-button-icon")) {
setIsDragging(true);
setDragOffset({
x: e.clientX - pipPosition.x,
@@ -209,7 +210,7 @@ export function CallWindow() {
>
<div className="call-header">
<div className="window-controls">
<mdui-button-icon
<MaterialIconButton
onClick={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn"
@@ -302,15 +303,15 @@ export function CallWindow() {
<div className="call-controls">
{status === "calling" && !isInitiator ? (
<>
<mdui-button-icon onClick={acceptCall} icon="call" />
<mdui-button-icon onClick={rejectCall} icon="call_end" />
<MaterialIconButton onClick={acceptCall} icon="call" />
<MaterialIconButton onClick={rejectCall} icon="call_end" />
</>
) : (
<>
<mdui-button-icon onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
<mdui-button-icon onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
<mdui-button-icon onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
<mdui-button-icon onClick={endCall} icon="call_end" />
<MaterialIconButton onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
<MaterialIconButton onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
<MaterialIconButton onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
<MaterialIconButton onClick={endCall} icon="call_end" />
</>
)}
</div>
@@ -1,6 +1,7 @@
import { useAppState } from "@/pages/chat/state";
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();
@@ -49,11 +50,11 @@ export function MinimizedCallBar() {
<div className="call-actions" onClick={(e) => e.stopPropagation()}>
{call.status === "calling" && !call.isInitiator ? (
<mdui-button-icon onClick={endCall} icon="call_end" />
<MaterialIconButton onClick={endCall} icon="call_end" />
) : (
<>
<mdui-button-icon onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
<mdui-button-icon onClick={endCall} icon="call_end" />
<MaterialIconButton onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
<MaterialIconButton onClick={endCall} icon="call_end" />
</>
)}
</div>