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
+3
View File
@@ -69,6 +69,9 @@
"reveal": "always",
"focus": false,
"panel": "shared"
},
"runOptions": {
"runOn": "folderOpen"
}
},
{
+1
View File
@@ -157,6 +157,7 @@ class DeviceSession(Base):
raw_user_agent = Column(Text, nullable=True)
# Parsed fields
device_name = Column(String(128), nullable=True)
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
os_name = Column(String(64), nullable=True)
os_version = Column(String(64), nullable=True)
+15
View File
@@ -7,6 +7,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
from utils import create_token, get_password_hash, verify_password
from validation import is_valid_password, is_valid_username, is_valid_display_name
@@ -51,12 +52,14 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request =
# Create device session and embed into JWT
raw_ua = http.headers.get("user-agent") if http else None
device_name = http.headers.get("x-device-name") if http else None
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=user.id,
raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
@@ -161,11 +164,13 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ
# Create initial device session
raw_ua = http.headers.get("user-agent") if http else None
device_name = http.headers.get("x-device-name") if http else None
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=new_user.id,
raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
@@ -261,9 +266,19 @@ def delete_user_as_owner(
@router.get("/logout")
def logout(
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Revoke current session
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if payload and payload.get("session_id"):
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id == payload["session_id"],
).update({DeviceSession.revoked: True})
current_user.online = False
current_user.last_seen = datetime.now()
db.commit()
+2 -1
View File
@@ -28,7 +28,7 @@ def list_devices(
current_session_id = _get_current_session_id(credentials)
sessions = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == current_user.id)
.filter(DeviceSession.user_id == current_user.id, DeviceSession.revoked == False)
.order_by(DeviceSession.last_seen.desc())
.all()
)
@@ -37,6 +37,7 @@ def list_devices(
{
"session_id": s.session_id,
"device_type": s.device_type,
"device_name": s.device_name,
"os_name": s.os_name,
"os_version": s.os_version,
"browser_name": s.browser_name,
+1 -2
View File
@@ -3,7 +3,7 @@ import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils";
import { API_BASE_URL } from "@/core/config";
import { importPassword, hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
/**
* Generates authentication headers for API requests
@@ -151,7 +151,6 @@ export function getAuthToken(): string | null {
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
*/
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
const key = await importPassword(password);
// Use per-user salt derived from username; in future we can fetch a server-provided salt
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
+1
View File
@@ -3,6 +3,7 @@ import { getAuthHeaders } from "@/core/api/authApi";
export interface DeviceInfo {
session_id: string;
device_name?: string;
device_type?: string;
os_name?: string;
os_version?: string;
@@ -1,9 +0,0 @@
import type { TextField } from "mdui/components/text-field";
interface TextFieldProps extends React.ComponentPropsWithoutRef<"mdui-text-field"> {
ref?: React.Ref<TextField>
}
export function MaterialTextField({ ref, ...props }: TextFieldProps) {
return <mdui-text-field autocomplete="off" ref={ref as React.Ref<HTMLElement>} {...props} />
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react";
import "./css/searchBar.scss";
import { MaterialIcon } from "@/utils/material";
interface SearchBarProps {
placeholder: string;
@@ -57,9 +58,9 @@ export default function SearchBar({
function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) {
if (icon === null) return null;
if (!icon) {
return defaultIcon ? <mdui-icon name={defaultIcon}></mdui-icon> : null;
return defaultIcon ? <MaterialIcon name={defaultIcon} /> : null;
} else if (typeof icon === 'string') {
return <mdui-icon name={icon}></mdui-icon>;
return <MaterialIcon name={icon} />;
} else {
return icon;
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/profileApi";
import { useAppState } from "@/pages/chat/state";
import { MaterialIcon } from "@/utils/material";
interface StatusBadgeProps {
verified: boolean;
@@ -33,7 +34,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
if (verified) {
return (
<span className={`${className} verified`} title="Подтверждённый аккаунт">
<mdui-icon name="verified--filled" />
<MaterialIcon name="verified--filled" />
</span>
);
}
@@ -41,7 +42,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
if (isSimilarToVerified) {
return (
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
<mdui-icon name="warning" />
<MaterialIcon name="warning--filled" />
</span>
);
}
@@ -1,6 +1,7 @@
import { useState } from "react";
import { verifyUser } from "@/core/api/profileApi";
import { useAppState } from "@/pages/chat/state";
import { MaterialButton } from "@/utils/material";
interface VerifyButtonProps {
userId: number;
@@ -34,13 +35,13 @@ export function VerifyButton({ userId, verified, onVerificationChange }: VerifyB
}
return (
<mdui-button
<MaterialButton
variant="filled"
loading={isVerifying}
onClick={handleVerifyToggle}
title={verified ? "Снять подтверждение" : "Подтвердить аккаунт"}
>
{verified ? "Отменить подтверждение" : "Подтвердить"}
</mdui-button>
</MaterialButton>
);
}
+7
View File
@@ -631,3 +631,10 @@ export interface StopDmTypingRequest extends WebSocketMessage {
recipientId: number;
};
}
// -------------
// Utility types
// -------------
export type Override<TBase, TExt> = Omit<TBase, keyof TExt> & TExt;
+3 -2
View File
@@ -7,12 +7,12 @@ import { API_BASE_URL } from "@/core/config";
import { useRef } from "react";
import type { TextField } from "mdui/components/text-field";
import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/MaterialTextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { useNavigate } from "react-router-dom";
import "./auth.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialTextField } from "@/utils/material";
export default function LoginPage() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
@@ -114,6 +114,7 @@ export default function LoginPage() {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
<MaterialTextField
label="@Имя пользователя"
id="login-username"
@@ -136,7 +137,7 @@ export default function LoginPage() {
required
ref={passwordElement} />
<mdui-button type="submit">Войти</mdui-button>
<MaterialButton type="submit">Войти</MaterialButton>
</form>
<div className="text-center">
+2 -2
View File
@@ -6,7 +6,7 @@ import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
import { API_BASE_URL } from "@/core/config";
import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/MaterialTextField";
import { MaterialButton, MaterialTextField } from "@/utils/material";
import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
import { useNavigate } from "react-router-dom";
import "./auth.scss";
@@ -156,7 +156,7 @@ export default function RegisterPage() {
required
ref={confirmPasswordElement} />
<mdui-button type="submit">Зарегистрироваться</mdui-button>
<MaterialButton type="submit">Зарегистрироваться</MaterialButton>
</form>
<div className="text-center">
@@ -0,0 +1,36 @@
.change-password-dialog .cpd-container {
padding: 16px;
.cpd-titlebar {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 16px;
gap: 10px;
.cpd-title {
font-size: 20px;
}
}
.cpd-content form {
display: flex;
flex-direction: column;
gap: 16px;
.cpd-logout-all {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.cpd-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
@use "right-panel" as *;
.chat-input-wrapper {
position: relative;
margin: 0 10px 10px 10px;
margin: 0 10px - $scrollbar-width 10px 10px;
position: sticky;
bottom: 10px;
z-index: 1;
-9
View File
@@ -27,15 +27,6 @@
width: 100%;
flex-direction: column;
overflow: hidden;
.chat-main {
flex-grow: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
overflow-y: auto;
}
}
}
+135 -96
View File
@@ -2,76 +2,27 @@
@use "../../../css/material" as *;
@use "sass:color";
.chat-main {
.chat-header {
padding: 16px;
background: rgba($color-dark-surface-container, 0.8);
backdrop-filter: blur(20px);
$scrollbar-width: 8px;
.chat-wrapper {
flex-grow: 1;
height: 100%;
position: relative;
overflow: hidden;
.chat-main {
display: flex;
align-items: center;
box-shadow: 0 4px 20px rgba($color-dark-primary, 0.1);
flex-direction: column;
height: 100%;
position: relative;
z-index: 5;
position: sticky;
top: 0;
overflow-y: auto;
.chat-header-avatar {
width: 45px;
height: 45px;
border-radius: 20%;
object-fit: cover;
margin-right: 1rem;
}
.chat-header-info {
display: flex;
justify-content: space-between;
align-items: center;
flex: 1;
.info-chat {
display: flex;
flex-direction: column;
h4 {
font-size: 1.1rem;
margin: 0 0 0.2rem;
}
p {
margin: 0;
font-size: 0.8rem;
color: #718096;
}
}
a {
display: flex;
flex-direction: row;
text-decoration: none;
color: white;
justify-content: end;
padding: 0;
margin: 0;
position: absolute;
right: 2%;
top: 2%;
&:hover {
border: none;
}
}
}
}
.chat-messages {
flex: 1;
padding: 10px 20px;
position: relative;
z-index: 1;
// Fancy floating thin scrollbar with invisible transparent margin
scrollbar-width: thin;
scrollbar-color: rgba($color-dark-primary, 0.3) transparent;
&::-webkit-scrollbar {
width: 7px;
width: $scrollbar-width;
}
&::-webkit-scrollbar-track {
@@ -79,46 +30,134 @@
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 20px;
background: rgba($color-dark-primary, 0.3);
border-radius: 4px;
margin: 2px;
&:hover {
background: rgba($color-dark-primary, 0.5);
}
}
}
.file-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
backdrop-filter: blur(20px);
.file-overlay-wrapper {
.chat-header {
padding: 16px;
margin: 10px 10px - $scrollbar-width 0 10px;
background: rgba($color-dark-surface-container, 0.7);
backdrop-filter: blur(20px);
border-radius: 30px;
outline: 3px dashed $color-dark-primary;
outline-offset: -20px;
height: 100%;
width: 100%;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: sticky;
top: 10px;
z-index: 5;
.file-overlay-inner {
.chat-header-avatar {
width: 45px;
height: 45px;
border-radius: 20%;
object-fit: cover;
margin-right: 1rem;
}
.chat-header-info {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(18, 18, 18, 0.8);
border: 1px solid $color-dark-surface-container-high;
border-radius: 12px;
color: $color-dark-on-surface;
flex: 1;
mdui-icon {
color: $color-dark-primary;
.info-chat {
display: flex;
flex-direction: column;
h4 {
font-size: 1.1rem;
margin: 0 0 0.2rem;
}
p {
margin: 0;
font-size: 0.8rem;
color: #718096;
}
}
a {
display: flex;
flex-direction: row;
text-decoration: none;
color: white;
justify-content: end;
padding: 0;
margin: 0;
position: absolute;
right: 2%;
top: 2%;
&:hover {
border: none;
}
}
}
}
.chat-messages {
flex: 1;
padding: 10px 20px;
position: relative;
z-index: 1;
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 20px;
}
}
.file-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
backdrop-filter: blur(20px);
.file-overlay-wrapper {
border-radius: 30px;
outline: 3px dashed $color-dark-primary;
outline-offset: -20px;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
.file-overlay-inner {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: rgba(18, 18, 18, 0.8);
border: 1px solid $color-dark-surface-container-high;
border-radius: 12px;
color: $color-dark-on-surface;
mdui-icon {
color: $color-dark-primary;
}
}
}
}
+1
View File
@@ -12,3 +12,4 @@
@use "profile-dialog";
@use "typing-indicators";
@use "suspension-dialog";
@use "changePasswordDialog";
+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>
@@ -1,3 +1,4 @@
import { MaterialButton } from "@/utils/material";
import "./download-app.scss";
export default function DownloadAppPage() {
@@ -11,7 +12,7 @@ export default function DownloadAppPage() {
</p>
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<mdui-button>Скачать на GitHub</mdui-button>
<MaterialButton>Скачать на GitHub</MaterialButton>
</a>
<p>
@@ -19,7 +20,7 @@ export default function DownloadAppPage() {
</p>
<a href="https://t.me/denis0001-dev">
<mdui-button>Написать в поддержку</mdui-button>
<MaterialButton>Написать в поддержку</MaterialButton>
</a>
</div>
</div>
+32 -30
View File
@@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom";
import { useAppState } from "@/pages/chat/state";
import "./home.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon } from "@/utils/material";
function GitHubLink({ children }: { children: React.ReactNode }) {
return (
@@ -32,9 +33,9 @@ export default function HomePage() {
}
const openBtn = (
<mdui-button variant="filled" onClick={handleGetStarted}>
<MaterialButton variant="filled" onClick={handleGetStarted}>
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
</mdui-button>
</MaterialButton>
);
return (
@@ -48,10 +49,10 @@ export default function HomePage() {
</div>
<nav className="header-nav">
<GitHubLink>
<mdui-button variant="text">GitHub</mdui-button>
<MaterialButton variant="text">GitHub</MaterialButton>
</GitHubLink>
<SupportLink>
<mdui-button variant="text">Поддержка</mdui-button>
<MaterialButton variant="text">Поддержка</MaterialButton>
</SupportLink>
{openBtn}
@@ -73,12 +74,13 @@ export default function HomePage() {
</p>
<div className="hero-actions">
{openBtn}
{!isMobile && <mdui-button
variant="outlined"
onClick={() => navigate("/register")}
>
Зарегистрироваться
</mdui-button>}
{!isMobile && (
<MaterialButton
variant="outlined"
onClick={() => navigate("/register")}>
Зарегистрироваться
</MaterialButton>
)}
</div>
</div>
<div className="hero-visual">
@@ -122,7 +124,7 @@ export default function HomePage() {
<div className="features-grid">
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="security" />
<MaterialIcon name="security" />
</div>
<h4>End-to-End Шифрование</h4>
<p>
@@ -133,7 +135,7 @@ export default function HomePage() {
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="code" />
<MaterialIcon name="code" />
</div>
<h4>100% открытый код</h4>
<p>
@@ -144,7 +146,7 @@ export default function HomePage() {
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="attach_file" />
<MaterialIcon name="attach_file" />
</div>
<h4>Обмен Файлами</h4>
<p>
@@ -155,7 +157,7 @@ export default function HomePage() {
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="notifications" />
<MaterialIcon name="notifications" />
</div>
<h4>Уведомления</h4>
<p>
@@ -166,7 +168,7 @@ export default function HomePage() {
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="edit" />
<MaterialIcon name="edit" />
</div>
<h4>Редактирование</h4>
<p>
@@ -177,7 +179,7 @@ export default function HomePage() {
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="computer" />
<MaterialIcon name="computer" />
</div>
<h4>Кроссплатформенность</h4>
<p>
@@ -205,20 +207,20 @@ export default function HomePage() {
target="_blank"
rel="noopener noreferrer"
>
<mdui-button variant="filled">
<mdui-icon name="download" slot="icon" />
<MaterialButton variant="filled">
<MaterialIcon name="download" slot="icon" />
Скачать для ПК
</mdui-button>
</MaterialButton>
</a>
<mdui-button variant="outlined" onClick={() => navigate("/login")}>
<mdui-icon name="language" slot="icon" />
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
<MaterialIcon name="language" slot="icon" />
Веб-версия
</mdui-button>
</MaterialButton>
</>
) : (
<mdui-button variant="filled" onClick={() => navigate("/download-app")}>
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
Скачать приложение
</mdui-button>
</MaterialButton>
)}
</div>
</div>
@@ -234,21 +236,21 @@ export default function HomePage() {
</p>
<div className="cta-actions">
{isMobile ? (
<mdui-button variant="filled" onClick={() => navigate("/download-app")}>
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
Скачать приложение
</mdui-button>
</MaterialButton>
) : (
<>
<mdui-button
<MaterialButton
variant="filled"
onClick={() => navigate("/register")}>
Создать аккаунт
</mdui-button>
<mdui-button
</MaterialButton>
<MaterialButton
variant="outlined"
onClick={() => navigate("/login")}>
Войти
</mdui-button>
</MaterialButton>
</>
)}
</div>
@@ -1,5 +1,6 @@
import { useNavigate } from "react-router-dom";
import "./not-found.scss";
import { MaterialButton, MaterialIcon } from "@/utils/material";
export default function NotFoundPage() {
const navigate = useNavigate();
@@ -14,22 +15,22 @@ export default function NotFoundPage() {
К сожалению, запрашиваемая страница не существует или была перемещена.
</p>
<div className="not-found-actions">
<mdui-button
<MaterialButton
variant="filled"
onClick={() => navigate("/")}
>
На главную
</mdui-button>
<mdui-button
</MaterialButton>
<MaterialButton
variant="outlined"
onClick={() => navigate(-1)}
>
Назад
</mdui-button>
</MaterialButton>
</div>
</div>
<div className="not-found-illustration">
<mdui-icon name="search_off"></mdui-icon>
<MaterialIcon name="search_off" />
</div>
</div>
</div>
-29
View File
@@ -1,29 +0,0 @@
/**
* @fileoverview MDUI component imports and configuration
* @description Imports all required MDUI components and sets up the theme
* @author Cursor
* @version 1.0.0
*/
import 'mdui/components/tabs';
import 'mdui/components/tab';
import 'mdui/components/tab-panel';
import 'mdui/components/list';
import 'mdui/components/list-item';
import 'mdui/components/bottom-app-bar';
import 'mdui/components/button-icon';
import 'mdui/components/fab';
import 'mdui/components/dialog';
import 'mdui/components/button';
import 'mdui/components/text-field';
import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch';
import 'mdui/components/chip';
import "mdui/mdui.css";
import 'mdui/components/circular-progress';
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
setColorScheme("#91cef4");
+145
View File
@@ -0,0 +1,145 @@
/**
* @fileoverview MDUI component imports and configuration
* @description Imports all required MDUI components and sets up the theme
* @author Cursor
* @version 1.0.0
*/
import 'mdui/components/tabs';
import 'mdui/components/tab';
import 'mdui/components/tab-panel';
import 'mdui/components/list';
import 'mdui/components/list-item';
import 'mdui/components/bottom-app-bar';
import 'mdui/components/button-icon';
import 'mdui/components/fab';
import 'mdui/components/dialog';
import 'mdui/components/button';
import 'mdui/components/text-field';
import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch';
import 'mdui/components/chip';
import "mdui/mdui.css";
import 'mdui/components/circular-progress';
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref } from 'react';
import type { TextField } from 'mdui/components/text-field';
import type { Switch } from 'mdui/components/switch';
import type { Override } from '@/core/types';
import type { Button } from 'mdui/components/button';
import type { ButtonIcon } from 'mdui/components/button-icon';
import type { Icon } from 'mdui/components/icon';
import type { Fab } from 'mdui/components/fab';
import type { Tabs } from 'mdui/components/tabs';
import type { Tab } from 'mdui/components/tab';
import type { TabPanel } from 'mdui/components/tab-panel';
import type { List } from 'mdui/components/list';
import type { ListItem } from 'mdui/components/list-item';
import type { Badge } from 'mdui/components/badge';
import type { CircularProgress } from 'mdui/components/circular-progress';
import type { BottomAppBar } from 'mdui/components/bottom-app-bar';
setColorScheme("#91cef4");
type BasePropCustomization<Tag extends keyof React.JSX.IntrinsicElements, Type> = Override<ComponentPropsWithoutRef<Tag>, {
ref?: Ref<Type>;
onInput?: (event: Override<FormEventHandler<Type>, { target: Type }>) => void;
onChange?: (event: Override<ChangeEventHandler<Type>, { target: Type }>) => void;
}>;
type NoChildren<T> = Omit<T, "children">;
// -----------------------------
// Normalized HTML element types
// -----------------------------
export type MDUITextField = Override<HTMLElement, TextField>;
export type MDUISwitch = Override<HTMLElement, Switch>;
export type MDUIButton = Override<HTMLElement, Button>;
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
export type MDUIIcon = Override<HTMLElement, Icon>;
export type MDUIFab = Override<HTMLElement, Fab>;
export type MDUITabs = Override<HTMLElement, Tabs>;
export type MDUITab = Override<HTMLElement, Tab>;
export type MDUITabPanel = Override<HTMLElement, TabPanel>;
export type MDUIList = Override<HTMLElement, List>;
export type MDUIListItem = Override<HTMLElement, ListItem>;
export type MDUIBadge = Override<HTMLElement, Badge>;
export type MDUICircularProgress = Override<HTMLElement, CircularProgress>;
export type MDUIBottomAppBar = Override<HTMLElement, BottomAppBar>;
// -------------------------------------------
// MDUI components wrapped in React components
// -------------------------------------------
export type MaterialTextFieldProps = BasePropCustomization<"mdui-text-field", MDUITextField>;
export function MaterialTextField(props: MaterialTextFieldProps) {
return <mdui-text-field autocomplete="off" {...props as ComponentProps<"mdui-text-field">} />
}
export type MaterialSwitchProps = BasePropCustomization<"mdui-switch", MDUISwitch>;
export function MaterialSwitch(props: MaterialSwitchProps) {
return <mdui-switch {...props as ComponentProps<"mdui-switch">} />
}
export type MaterialButtonProps = BasePropCustomization<"mdui-button", MDUIButton>;
export function MaterialButton(props: MaterialButtonProps) {
return <mdui-button {...props as ComponentProps<"mdui-button">} />
}
export type MaterialIconButtonProps = NoChildren<BasePropCustomization<"mdui-button-icon", MDUIButtonIcon>>;
export function MaterialIconButton(props: MaterialIconButtonProps) {
return <mdui-button-icon {...props as ComponentProps<"mdui-button-icon">} />
}
export type MaterialIconProps = NoChildren<BasePropCustomization<"mdui-icon", MDUIIcon>>;
export function MaterialIcon(props: MaterialIconProps) {
return <mdui-icon {...props as ComponentProps<"mdui-icon">} />
}
export type MaterialFabProps = NoChildren<BasePropCustomization<"mdui-fab", MDUIFab>>;
export function MaterialFab(props: MaterialFabProps) {
return <mdui-fab {...props as ComponentProps<"mdui-fab">} />
}
export type MaterialTabsProps = BasePropCustomization<"mdui-tabs", MDUITabs>;
export function MaterialTabs(props: MaterialTabsProps) {
return <mdui-tabs {...props as ComponentProps<"mdui-tabs">} />
}
export type MaterialTabProps = BasePropCustomization<"mdui-tab", MDUITab>;
export function MaterialTab(props: MaterialTabProps) {
return <mdui-tab {...props as ComponentProps<"mdui-tab">} />
}
export type MaterialTabPanelProps = BasePropCustomization<"mdui-tab-panel", MDUITabPanel>;
export function MaterialTabPanel(props: MaterialTabPanelProps) {
return <mdui-tab-panel {...props as ComponentProps<"mdui-tab-panel">} />
}
export type MaterialListProps = BasePropCustomization<"mdui-list", MDUIList>;
export function MaterialList(props: MaterialListProps) {
return <mdui-list {...props as ComponentProps<"mdui-list">} />
}
export type MaterialListItemProps = BasePropCustomization<"mdui-list-item", MDUIListItem>;
export function MaterialListItem(props: MaterialListItemProps) {
return <mdui-list-item {...props as ComponentProps<"mdui-list-item">} />
}
export type MaterialBadgeProps = BasePropCustomization<"mdui-badge", MDUIBadge>;
export function MaterialBadge(props: MaterialBadgeProps) {
return <mdui-badge {...props as ComponentProps<"mdui-badge">} />
}
export type MaterialCircularProgressProps = NoChildren<BasePropCustomization<"mdui-circular-progress", MDUICircularProgress>>;
export function MaterialCircularProgress(props: MaterialCircularProgressProps) {
return <mdui-circular-progress {...props as ComponentProps<"mdui-circular-progress">} />
}
export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>;
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
}
+27
View File
@@ -1,2 +1,29 @@
/// <reference types="vite/client" />
/// <reference types="mdui/jsx.en.d.ts" />
/// <reference types="./core/types.d.ts" />
declare global {
namespace React {
// Augment React synthetic events to provide typed target for ALL HTML elements
interface SyntheticEvent<T = Element, E = Event> {
target: EventTarget & T;
}
}
// Augment DOM event listeners to provide typed target for ALL elements
// This works by augmenting the base Element interface which covers all HTML, SVG, etc.
interface Element {
addEventListener<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: Element, ev: HTMLElementEventMap[K] & { target: Element }) => any,
options?: boolean | AddEventListenerOptions
): void;
removeEventListener<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: Element, ev: HTMLElementEventMap[K] & { target: Element }) => any,
options?: boolean | EventListenerOptions
): void;
}
}
export {};