From fc11d06570255e995ba5f956d42a885c30a60e44 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 1 Nov 2025 14:55:36 +0300 Subject: [PATCH] Create new Material components, redesign the chat UI, add security settings --- .vscode/tasks.json | 3 + backend/models.py | 1 + backend/routes/account.py | 15 + backend/routes/devices.py | 3 +- frontend/src/core/api/authApi.ts | 3 +- frontend/src/core/api/devicesApi.ts | 1 + .../src/core/components/MaterialTextField.tsx | 9 - frontend/src/core/components/SearchBar.tsx | 5 +- frontend/src/core/components/StatusBadge.tsx | 5 +- frontend/src/core/components/VerifyButton.tsx | 5 +- frontend/src/core/types.d.ts | 9 +- frontend/src/pages/auth/LoginPage.tsx | 5 +- frontend/src/pages/auth/RegisterPage.tsx | 4 +- .../pages/chat/css/_changePasswordDialog.scss | 36 ++ frontend/src/pages/chat/css/_chat-input.scss | 3 +- frontend/src/pages/chat/css/_layout.scss | 9 - frontend/src/pages/chat/css/_right-panel.scss | 237 ++++++----- frontend/src/pages/chat/css/chat.scss | 3 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 23 +- .../src/pages/chat/ui/SuspensionDialog.tsx | 3 +- frontend/src/pages/chat/ui/left/ChatTabs.tsx | 28 +- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 22 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 23 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 15 +- .../chat/ui/left/profile/CropperDialog.tsx | 7 +- .../chat/ui/left/profile/ImageCropper.tsx | 9 +- .../ui/left/settings/ChangePasswordDialog.tsx | 82 ++++ .../chat/ui/left/settings/SettingsDialog.tsx | 226 +++++------ .../pages/chat/ui/right/ChatInputWrapper.tsx | 21 +- .../src/pages/chat/ui/right/ChatMessages.tsx | 5 +- frontend/src/pages/chat/ui/right/Message.tsx | 33 +- .../chat/ui/right/MessagePanelRenderer.tsx | 375 +++++++++--------- .../pages/chat/ui/right/calls/CallWindow.tsx | 19 +- .../chat/ui/right/calls/MinimizedCallBar.tsx | 7 +- .../pages/download-app/DownloadAppPage.tsx | 5 +- frontend/src/pages/home/HomePage.tsx | 62 +-- frontend/src/pages/not-found/NotFoundPage.tsx | 11 +- frontend/src/utils/material.ts | 29 -- frontend/src/utils/material.tsx | 145 +++++++ frontend/src/vite-env.d.ts | 29 +- 40 files changed, 917 insertions(+), 618 deletions(-) delete mode 100644 frontend/src/core/components/MaterialTextField.tsx create mode 100644 frontend/src/pages/chat/css/_changePasswordDialog.scss create mode 100644 frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx delete mode 100644 frontend/src/utils/material.ts create mode 100644 frontend/src/utils/material.tsx diff --git a/.vscode/tasks.json b/.vscode/tasks.json index df1c2c7..9dd345a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -69,6 +69,9 @@ "reveal": "always", "focus": false, "panel": "shared" + }, + "runOptions": { + "runOn": "folderOpen" } }, { diff --git a/backend/models.py b/backend/models.py index b1dcb90..8d581ee 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routes/account.py b/backend/routes/account.py index de70625..d73ecf3 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -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() diff --git a/backend/routes/devices.py b/backend/routes/devices.py index 144ffb6..550c5f9 100644 --- a/backend/routes/devices.py +++ b/backend/routes/devices.py @@ -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, diff --git a/frontend/src/core/api/authApi.ts b/frontend/src/core/api/authApi.ts index 78edac5..a95c08f 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/authApi.ts @@ -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 { - 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 diff --git a/frontend/src/core/api/devicesApi.ts b/frontend/src/core/api/devicesApi.ts index a6a48da..12882cd 100644 --- a/frontend/src/core/api/devicesApi.ts +++ b/frontend/src/core/api/devicesApi.ts @@ -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; diff --git a/frontend/src/core/components/MaterialTextField.tsx b/frontend/src/core/components/MaterialTextField.tsx deleted file mode 100644 index 287716d..0000000 --- a/frontend/src/core/components/MaterialTextField.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { TextField } from "mdui/components/text-field"; - -interface TextFieldProps extends React.ComponentPropsWithoutRef<"mdui-text-field"> { - ref?: React.Ref -} - -export function MaterialTextField({ ref, ...props }: TextFieldProps) { - return } {...props} /> -} \ No newline at end of file diff --git a/frontend/src/core/components/SearchBar.tsx b/frontend/src/core/components/SearchBar.tsx index af8d919..ae97265 100644 --- a/frontend/src/core/components/SearchBar.tsx +++ b/frontend/src/core/components/SearchBar.tsx @@ -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 ? : null; + return defaultIcon ? : null; } else if (typeof icon === 'string') { - return ; + return ; } else { return icon; } diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index 6ec9839..bce9df8 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -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 ( - + ); } @@ -41,7 +42,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro if (isSimilarToVerified) { return ( - + ); } diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 8f42bb8..07ed3de 100644 --- a/frontend/src/core/components/VerifyButton.tsx +++ b/frontend/src/core/components/VerifyButton.tsx @@ -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 ( - {verified ? "Отменить подтверждение" : "Подтвердить"} - + ); } \ No newline at end of file diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 90a89b8..864f04e 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -630,4 +630,11 @@ export interface StopDmTypingRequest extends WebSocketMessage { data: { recipientId: number; }; -} \ No newline at end of file +} + + +// ------------- +// Utility types +// ------------- + +export type Override = Omit & TExt; \ No newline at end of file diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 7111273..ad6de7a 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -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([]); @@ -114,6 +114,7 @@ export default function LoginPage() { showAlert("danger", "Ошибка соединения с сервером"); } }}> + - Войти + Войти
diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx index 28b31d3..93b8671 100644 --- a/frontend/src/pages/auth/RegisterPage.tsx +++ b/frontend/src/pages/auth/RegisterPage.tsx @@ -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} /> - Зарегистрироваться + Зарегистрироваться
diff --git a/frontend/src/pages/chat/css/_changePasswordDialog.scss b/frontend/src/pages/chat/css/_changePasswordDialog.scss new file mode 100644 index 0000000..a851c71 --- /dev/null +++ b/frontend/src/pages/chat/css/_changePasswordDialog.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/_chat-input.scss b/frontend/src/pages/chat/css/_chat-input.scss index 9be72b7..624f97b 100644 --- a/frontend/src/pages/chat/css/_chat-input.scss +++ b/frontend/src/pages/chat/css/_chat-input.scss @@ -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; diff --git a/frontend/src/pages/chat/css/_layout.scss b/frontend/src/pages/chat/css/_layout.scss index 2d4f023..07184c5 100644 --- a/frontend/src/pages/chat/css/_layout.scss +++ b/frontend/src/pages/chat/css/_layout.scss @@ -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; - } } } diff --git a/frontend/src/pages/chat/css/_right-panel.scss b/frontend/src/pages/chat/css/_right-panel.scss index dadbe0f..e24fad6 100644 --- a/frontend/src/pages/chat/css/_right-panel.scss +++ b/frontend/src/pages/chat/css/_right-panel.scss @@ -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,48 +30,136 @@ } &::-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; - - .file-overlay-inner { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + position: sticky; + top: 10px; + z-index: 5; + + .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; - - mdui-icon { - color: $color-dark-primary; + 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; + + &::-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; + } } } } } -} +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/chat.scss b/frontend/src/pages/chat/css/chat.scss index 8c1d58f..31bf234 100644 --- a/frontend/src/pages/chat/css/chat.scss +++ b/frontend/src/pages/chat/css/chat.scss @@ -11,4 +11,5 @@ @use "callWindow"; @use "profile-dialog"; @use "typing-indicators"; -@use "suspension-dialog"; \ No newline at end of file +@use "suspension-dialog"; +@use "changePasswordDialog"; \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 6785c30..1025fd7 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -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 (
- +
{valueComponent} @@ -442,12 +443,11 @@ export function ProfileDialog() { className="profile-dialog" afterChildren={ currentData.isOwnProfile && ( - + 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 && (
- +
)}
@@ -481,6 +481,7 @@ export function ProfileDialog() { onChange={handleDisplayNameChange} readOnly={!currentData.isOwnProfile} placeholder="Имя" /> +

Admin Actions

- {currentData.suspended ? "Unsuspend Account" : "Suspend Account"} - - + Delete Account - +
- +
diff --git a/frontend/src/pages/chat/ui/left/ChatTabs.tsx b/frontend/src/pages/chat/ui/left/ChatTabs.tsx index 5733364..39e1e24 100644 --- a/frontend/src/pages/chat/ui/left/ChatTabs.tsx +++ b/frontend/src/pages/chat/ui/left/ChatTabs.tsx @@ -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 (
- setActiveTab((e.target as Tabs).value as ChatTabs)}> - + onChange={(e) => setActiveTab(e.target.value as ChatTabs)}> + Чаты - - + + Каналы - - + + Контакты - + - + - - Скоро будет... - Скоро будет... - + + Скоро будет... + Скоро будет... +
); } diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index c0c5d85..ded8778 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -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 ( <> - - onSettingsOpenChange(true)}> - + + onSettingsOpenChange(true)} /> +
- - -
+ onClick={logout} + title="Выйти" /> + + ); diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 58dea0b..caa11fb 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -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 ( - - ); + return ; } return ( - + {allChats.map((chat) => { if (chat.type === "public") { return ( - handlePublicChatClick(chat.name)} @@ -255,11 +254,11 @@ export function UnifiedChatsList() { objectFit: "cover" }} /> - + ); } else { return ( - handleDMClick(chat)} @@ -288,20 +287,20 @@ export function UnifiedChatsList() { display: "block" }} onError={(e) => { - (e.target as HTMLImageElement).src = defaultAvatar; + e.target.src = defaultAvatar; }} />
{chat.unreadCount > 0 && ( - + {chat.unreadCount} - + )} - + ); } })} - + ); } diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index f91cfcf..6ca3389 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -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 ? ( - { e.stopPropagation(); @@ -134,7 +135,7 @@ export function UsernameSearch() { > {isSearching && (
- + Поиск...
)} @@ -146,9 +147,9 @@ export function UsernameSearch() { )} {!isSearching && searchResults.length > 0 && ( - + {searchResults.map((searchUser) => ( - handleUserClick(searchUser)} @@ -174,14 +175,14 @@ export function UsernameSearch() { display: "block" }} onError={(e) => { - (e.target as HTMLImageElement).src = defaultAvatar; + e.target.src = defaultAvatar; }} />
- + ))} - + )} {!isSearching && searchQuery.length < 2 && ( diff --git a/frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx b/frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx index 12e70b1..e3f2c31 100644 --- a/frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx +++ b/frontend/src/pages/chat/ui/left/profile/CropperDialog.tsx @@ -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() {

Обрезать фото профиля

- +
- Отмена - Сохранить + Отмена + Сохранить
diff --git a/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx b/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx index 7de4a5b..d0da4b4 100644 --- a/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx +++ b/frontend/src/pages/chat/ui/left/profile/ImageCropper.tsx @@ -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" />
- + Обрезать - - + + Отмена - +
); diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx new file mode 100644 index 0000000..a7c814a --- /dev/null +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -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 ( + +
+
+ onOpenChange(false)}> +
Изменить пароль
+
+
+
{ + 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); + } + }}> + setCurrent(e.target.value)} + variant="outlined" + toggle-password + required /> + setNext(e.target.value)} + variant="outlined" + toggle-password + required /> + setConfirm(e.target.value)} + variant="outlined" + toggle-password + required /> +
+ setLogoutAll(e.target.checked)} /> + +
+
+ Сохранить +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx index e7e61a9..8e9c461 100644 --- a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx @@ -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([]); - 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 ( - -
-
- onOpenChange(false)}> - Настройки -
-
- - handlePanelChange("notifications-settings")} - style={{ cursor: "pointer" }} - > - Уведомления - - handlePanelChange("security-settings")} - style={{ cursor: "pointer" }} - > - Безопасность - - handlePanelChange("devices-settings")} - style={{ cursor: "pointer" }} - > - Устройства - - handlePanelChange("about-settings")} - style={{ cursor: "pointer" }} - > - О приложении - - -
-
-

Уведомления

- {pushSupported && ( - handlePushNotificationToggle((e.target as Switch).checked)} - > - Push уведомления - - )} -
- - -
-

Безопасность

-
{ - 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); - } - }}> - setCpCurrent(e.target.value)} variant="outlined" toggle-password> - setCpNext(e.target.value)} variant="outlined" toggle-password> - setCpConfirm(e.target.value)} variant="outlined" toggle-password> -
- setCpLogoutAll(e.target.checked)}>Выйти на всех устройствах (кроме текущего) -
- Сохранить -
-
-
- -
-

Язык

- - Русский - English - Español - -
- -
-

Хранилище

-

Использовано: 2.5 ГБ из 10 ГБ

- - Очистить кэш -
- -
-

Устройства

-
- { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах - { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве + <> + +
+
+ onOpenChange(false)}> +
Настройки
+
+
+ + handlePanelChange("notifications-settings")} + style={{ cursor: "pointer" }} + > + Уведомления + + handlePanelChange("security-settings")} + style={{ cursor: "pointer" }} + > + Безопасность + + handlePanelChange("devices-settings")} + style={{ cursor: "pointer" }} + > + Устройства + + handlePanelChange("about-settings")} + style={{ cursor: "pointer" }} + > + О приложении + + +
+
+

Уведомления

+ {pushSupported && ( + handlePushNotificationToggle(e.target.checked)}> + Push уведомления + + )}
- - {devices.map((d) => ( - { if (!user.authToken || d.current) return; await revokeDevice(user.authToken, d.session_id); const list = await listDevices(user.authToken); updateDevices(() => list); }}> -
{d.browser_name || "Браузер"} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}
-
Последняя активность: {d.last_seen || "—"}
-
- ))} -
-
-
-

О приложении

-

100% open source. Репозиторий на GitHub.

-

{PRODUCT_NAME}

+
+

Безопасность

+ setCpOpen(true)}>Изменить пароль +
+ +
+

Устройства

+
+ { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах + { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве +
+ + {devices.map((d) => ( + { + if (!user.authToken || d.current) return; + await revokeDevice(user.authToken, d.session_id); + const list = await listDevices(user.authToken); + updateDevices(() => list); + }} + > +
{d.device_name || (d.browser_name || "Браузер")} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}
+
Последняя активность: {d.last_seen || "—"}
+
+ ))} +
+
+ +
+

О приложении

+

100% open source. Репозиторий на GitHub.

+

{PRODUCT_NAME}

+
-
- + + + ); } diff --git a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx index eabd33a..319a54c 100644 --- a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx +++ b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx @@ -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" }} >
- + {editingMessage!.username} {editingMessage!.content} - +
)} @@ -175,12 +176,12 @@ export function ChatInputWrapper( style={{ overflow: "hidden" }} >
- + {replyTo!.username} {replyTo!.content} - +
)} @@ -195,7 +196,7 @@ export function ChatInputWrapper( style={{ overflow: "hidden" }} >
- +
{selectedFiles.map((file, i) => ( - + {file.name} ))}
- setAttachmentsVisible(false)}> + setAttachmentsVisible(false)}>
)}
- e.stopPropagation()} @@ -240,7 +241,7 @@ export function ChatInputWrapper( onTextChange={handleMessageChange} onEnter={handleSubmit} />
- + @@ -250,7 +251,7 @@ export function ChatInputWrapper(
Ошибка
Общий размер вложений превышает 4 ГБ.
- setErrorOpen(false)}>Закрыть + setErrorOpen(false)}>Закрыть
- setDeleteDialogOpen(false)}>Отменить - Удалить + setDeleteDialogOpen(false)}>Отменить + Удалить {/* Context Menu */} diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index bdc0d81..0be90d6 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -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) { - 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; }} />
@@ -517,7 +516,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD onClick={handleLinkClick} /> {message.files && message.files.length > 0 && ( - + {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) && (
- +
)}
@@ -554,18 +553,18 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD await downloadFile(file); }} > - + - {isDownloading ? : null} + {isDownloading ? : null} {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} - + )}
); })} - + )} {message.runtimeData.sendingState.status === 'sending' && ( - + )} {message.runtimeData.sendingState.status === 'failed' && ( error @@ -617,13 +616,13 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD onClick={e => e.stopPropagation()} />
e.stopPropagation()}> - + {isDownloadingFullscreen ? (
- +
) : ( - + )}
, diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index 5714ab6..10d94aa 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -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) { { - 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}> -
- Avatar -
-
-

{panelState?.title || "Выбор чата"}

- + className="chat-wrapper" + > +
{ + 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}> +
+ Avatar +
+
+

{panelState?.title || "Выбор чата"}

+ +
+ {panel?.isDm() && ( + + )} +
- {panel?.isDm() && ( - + + {panelState?.isLoading ? ( +
+
+ Загрузка сообщений... +
+
+ ) : panelState && panel ? ( + { + 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)} + > +
+ + ) : ( +
+
+ Выберите чат на боковой панели, чтобы начать переписку +
+
+ )} + + {panel && ( + <> + + {isDragging && ( + e.preventDefault()} + onDrop={(e) => e.preventDefault()} + > +
+
+ + Отпустите файл(ы) для добавления +
+
+
+ )} +
+ + + { + 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(); + } + }} + /> + )}
-
- - {panelState?.isLoading ? ( -
-
- Загрузка сообщений... -
-
- ) : panelState && panel ? ( - { - 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)} - > -
- - ) : ( -
-
- Выберите чат на боковой панели, чтобы начать переписку -
-
- )} - - {panel && ( - <> - - {isDragging && ( - e.preventDefault()} - onDrop={(e) => e.preventDefault()} - > -
-
- - Отпустите файл(ы) для добавления -
-
-
- )} -
- - - { - 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(); - } - }} - /> - - )} diff --git a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx index b730a39..ae72e7f 100644 --- a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx +++ b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx @@ -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() { >
- {status === "calling" && !isInitiator ? ( <> - - + + ) : ( <> - - - - + + + + )}
diff --git a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx index b774f12..16124bf 100644 --- a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx +++ b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx @@ -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() {
e.stopPropagation()}> {call.status === "calling" && !call.isInitiator ? ( - + ) : ( <> - - + + )}
diff --git a/frontend/src/pages/download-app/DownloadAppPage.tsx b/frontend/src/pages/download-app/DownloadAppPage.tsx index 6f51ec8..666cbbd 100644 --- a/frontend/src/pages/download-app/DownloadAppPage.tsx +++ b/frontend/src/pages/download-app/DownloadAppPage.tsx @@ -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() {

- Скачать на GitHub + Скачать на GitHub

@@ -19,7 +20,7 @@ export default function DownloadAppPage() {

- Написать в поддержку + Написать в поддержку
diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index 8a501db..b8f5155 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -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 = ( - + {isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"} - + ); return ( @@ -48,10 +49,10 @@ export default function HomePage() {
@@ -122,7 +124,7 @@ export default function HomePage() {
- +

End-to-End Шифрование

@@ -133,7 +135,7 @@ export default function HomePage() {

- +

100% открытый код

@@ -144,7 +146,7 @@ export default function HomePage() {

- +

Обмен Файлами

@@ -155,7 +157,7 @@ export default function HomePage() {

- +

Уведомления

@@ -166,7 +168,7 @@ export default function HomePage() {

- +

Редактирование

@@ -177,7 +179,7 @@ export default function HomePage() {

- +

Кроссплатформенность

@@ -205,20 +207,20 @@ export default function HomePage() { target="_blank" rel="noopener noreferrer" > - - + + Скачать для ПК - + - navigate("/login")}> - + navigate("/login")}> + Веб-версия - + ) : ( - navigate("/download-app")}> + navigate("/download-app")}> Скачать приложение - + )}

@@ -234,21 +236,21 @@ export default function HomePage() {

{isMobile ? ( - navigate("/download-app")}> + navigate("/download-app")}> Скачать приложение - + ) : ( <> - navigate("/register")}> Создать аккаунт - - + navigate("/login")}> Войти - + )}
diff --git a/frontend/src/pages/not-found/NotFoundPage.tsx b/frontend/src/pages/not-found/NotFoundPage.tsx index d619fec..8e183f8 100644 --- a/frontend/src/pages/not-found/NotFoundPage.tsx +++ b/frontend/src/pages/not-found/NotFoundPage.tsx @@ -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() { К сожалению, запрашиваемая страница не существует или была перемещена.

- navigate("/")} > На главную - - + navigate(-1)} > Назад - +
- +
diff --git a/frontend/src/utils/material.ts b/frontend/src/utils/material.ts deleted file mode 100644 index f684d15..0000000 --- a/frontend/src/utils/material.ts +++ /dev/null @@ -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"); \ No newline at end of file diff --git a/frontend/src/utils/material.tsx b/frontend/src/utils/material.tsx new file mode 100644 index 0000000..2341733 --- /dev/null +++ b/frontend/src/utils/material.tsx @@ -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 = Override, { + ref?: Ref; + onInput?: (event: Override, { target: Type }>) => void; + onChange?: (event: Override, { target: Type }>) => void; +}>; + +type NoChildren = Omit; + +// ----------------------------- +// Normalized HTML element types +// ----------------------------- +export type MDUITextField = Override; +export type MDUISwitch = Override; +export type MDUIButton = Override; +export type MDUIButtonIcon = Override; +export type MDUIIcon = Override; +export type MDUIFab = Override; +export type MDUITabs = Override; +export type MDUITab = Override; +export type MDUITabPanel = Override; +export type MDUIList = Override; +export type MDUIListItem = Override; +export type MDUIBadge = Override; +export type MDUICircularProgress = Override; +export type MDUIBottomAppBar = Override; + +// ------------------------------------------- +// MDUI components wrapped in React components +// ------------------------------------------- + +export type MaterialTextFieldProps = BasePropCustomization<"mdui-text-field", MDUITextField>; +export function MaterialTextField(props: MaterialTextFieldProps) { + return } /> +} + +export type MaterialSwitchProps = BasePropCustomization<"mdui-switch", MDUISwitch>; +export function MaterialSwitch(props: MaterialSwitchProps) { + return } /> +} + +export type MaterialButtonProps = BasePropCustomization<"mdui-button", MDUIButton>; +export function MaterialButton(props: MaterialButtonProps) { + return } /> +} + +export type MaterialIconButtonProps = NoChildren>; +export function MaterialIconButton(props: MaterialIconButtonProps) { + return } /> +} + +export type MaterialIconProps = NoChildren>; +export function MaterialIcon(props: MaterialIconProps) { + return } /> +} + +export type MaterialFabProps = NoChildren>; +export function MaterialFab(props: MaterialFabProps) { + return } /> +} + +export type MaterialTabsProps = BasePropCustomization<"mdui-tabs", MDUITabs>; +export function MaterialTabs(props: MaterialTabsProps) { + return } /> +} + +export type MaterialTabProps = BasePropCustomization<"mdui-tab", MDUITab>; +export function MaterialTab(props: MaterialTabProps) { + return } /> +} + +export type MaterialTabPanelProps = BasePropCustomization<"mdui-tab-panel", MDUITabPanel>; +export function MaterialTabPanel(props: MaterialTabPanelProps) { + return } /> +} + +export type MaterialListProps = BasePropCustomization<"mdui-list", MDUIList>; +export function MaterialList(props: MaterialListProps) { + return } /> +} + +export type MaterialListItemProps = BasePropCustomization<"mdui-list-item", MDUIListItem>; +export function MaterialListItem(props: MaterialListItemProps) { + return } /> +} + +export type MaterialBadgeProps = BasePropCustomization<"mdui-badge", MDUIBadge>; +export function MaterialBadge(props: MaterialBadgeProps) { + return } /> +} + +export type MaterialCircularProgressProps = NoChildren>; +export function MaterialCircularProgress(props: MaterialCircularProgressProps) { + return } /> +} + +export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>; +export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) { + return } /> +} \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 1f13624..941c178 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,2 +1,29 @@ /// -/// \ No newline at end of file +/// +/// + +declare global { + namespace React { + // Augment React synthetic events to provide typed target for ALL HTML elements + interface SyntheticEvent { + 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( + type: K, + listener: (this: Element, ev: HTMLElementEventMap[K] & { target: Element }) => any, + options?: boolean | AddEventListenerOptions + ): void; + removeEventListener( + type: K, + listener: (this: Element, ev: HTMLElementEventMap[K] & { target: Element }) => any, + options?: boolean | EventListenerOptions + ): void; + } +} + +export {}; \ No newline at end of file