diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc
index 9c92b16..4d5ccf7 100644
--- a/.cursor/rules/general.mdc
+++ b/.cursor/rules/general.mdc
@@ -55,4 +55,7 @@ When working with this project, follow these rules:
## Styling
- Use SCSS modules
- Use nested styles
-- Put SCSS into one folder per page
\ No newline at end of file
+- Put SCSS into one folder per page
+
+## Animations with Framer Motion
+- Don't use variants if they are used only once
\ No newline at end of file
diff --git a/backend/dependencies.py b/backend/dependencies.py
index 5bdfcab..c19adee 100644
--- a/backend/dependencies.py
+++ b/backend/dependencies.py
@@ -1,8 +1,9 @@
+from datetime import datetime
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
-from utils import *
-from models import *
+from utils import verify_token
+from models import User, DeviceSession
from db import SessionLocal
security = HTTPBearer()
diff --git a/backend/push_service.py b/backend/push_service.py
index ee48ded..a06c36d 100644
--- a/backend/push_service.py
+++ b/backend/push_service.py
@@ -107,7 +107,7 @@ class PushNotificationService:
payload = {
"title": title,
"body": body,
- "icon": icon or "/logo.png",
+ "icon": icon or "about:blank",
"tag": f"message_{user_id}",
"data": data
}
diff --git a/backend/routes/account.py b/backend/routes/account.py
index 02fd8d3..445930e 100644
--- a/backend/routes/account.py
+++ b/backend/routes/account.py
@@ -53,7 +53,7 @@ def convert_user(user: User) -> dict:
"verified": user.verified,
"suspended": user.suspended or False,
"suspension_reason": user.suspension_reason,
- "deleted": user.deleted or False
+ "deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
}
@router.get("/check_auth")
@@ -313,6 +313,8 @@ def set_public_key(payload: dict, current_user: User = Depends(get_current_user)
pk = payload.get("publicKey")
if not pk:
raise HTTPException(status_code=400, detail="publicKey required")
+ if not isinstance(pk, str) or len(pk) > 10000 or len(pk) < 10:
+ raise HTTPException(status_code=400, detail="Invalid publicKey format")
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
if row:
row.public_key_b64 = pk
@@ -334,6 +336,8 @@ def set_backup(payload: dict, current_user: User = Depends(get_current_user), db
blob = payload.get("blob")
if not blob:
raise HTTPException(status_code=400, detail="blob required")
+ if not isinstance(blob, str) or len(blob) > 1000000: # 1MB limit
+ raise HTTPException(status_code=400, detail="Invalid blob format or size exceeds 1MB")
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
if row:
row.blob_json = blob
diff --git a/backend/routes/devices.py b/backend/routes/devices.py
index 550c5f9..7cf41b9 100644
--- a/backend/routes/devices.py
+++ b/backend/routes/devices.py
@@ -60,6 +60,9 @@ def revoke_device(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
+ if not session_id or len(session_id) > 64 or len(session_id) < 1:
+ raise HTTPException(status_code=400, detail="Invalid session ID")
+
s = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id)
diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py
index 2af4a9a..bd75081 100644
--- a/backend/routes/messaging.py
+++ b/backend/routes/messaging.py
@@ -164,8 +164,8 @@ def convert_message(msg: Message) -> dict:
"username": reaction.user.display_name
})
- # Handle deleted users
- if msg.author.deleted:
+ # Handle deleted or suspended users
+ if msg.author.deleted or msg.author.suspended:
username = f"Deleted User #{msg.author.id}"
profile_picture = None
verified = False
@@ -198,7 +198,7 @@ def convert_message(msg: Message) -> dict:
}
-def convert_dm_envelope(envelope: DMEnvelope) -> dict:
+def convert_dm_envelope(db: Session, envelope: DMEnvelope) -> dict:
# Group reactions by emoji
reactions_dict = {}
if envelope.reactions:
@@ -217,13 +217,10 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
})
# Get sender info for verified status
- from models import User
- from dependencies import get_db
- db = next(get_db())
sender = db.query(User).filter(User.id == envelope.sender_id).first()
- # Handle deleted users
- if sender and sender.deleted:
+ # Handle deleted or suspended users
+ if sender and (sender.deleted or sender.suspended):
sender_verified = False
else:
sender_verified = sender.verified if sender else False
@@ -454,9 +451,25 @@ async def dm_send(
if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}")
+ try:
+ recipient_id = int(payload["recipientId"])
+ except (ValueError, TypeError):
+ raise HTTPException(status_code=400, detail="Invalid recipientId")
+
+ if recipient_id <= 0:
+ raise HTTPException(status_code=400, detail="Invalid recipientId")
+
+ if recipient_id == current_user.id:
+ raise HTTPException(status_code=400, detail="Cannot send DM to yourself")
+
+ # Verify recipient exists
+ recipient = db.query(User).filter(User.id == recipient_id).first()
+ if not recipient or recipient.deleted or recipient.suspended:
+ raise HTTPException(status_code=404, detail="Recipient not found")
+
env = DMEnvelope(
sender_id=current_user.id,
- recipient_id=int(payload["recipientId"]),
+ recipient_id=recipient_id,
iv_b64=payload["iv"],
ciphertext_b64=payload["ciphertext"],
salt_b64=payload["salt"],
@@ -590,6 +603,17 @@ async def dm_fetch(request: Request, since: int | None = None, current_user: Use
@router.get("/dm/history/{other_user_id}")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ if other_user_id <= 0:
+ raise HTTPException(status_code=400, detail="Invalid user ID")
+
+ if other_user_id == current_user.id:
+ raise HTTPException(status_code=400, detail="Cannot get history with yourself")
+
+ # Verify other user exists
+ other_user = db.query(User).filter(User.id == other_user_id).first()
+ if not other_user or other_user.deleted or other_user.suspended:
+ raise HTTPException(status_code=404, detail="User not found")
+
return convert_envelopes(
db.query(DMEnvelope)
.filter(
@@ -631,7 +655,7 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge
result.append({
"user": convert_user(other_user),
- "lastMessage": convert_dm_envelope(latest_message),
+ "lastMessage": convert_dm_envelope(db, latest_message),
"unreadCount": unread_count
})
@@ -833,7 +857,7 @@ async def add_dm_reaction(
# Refresh envelope to get updated reactions
db.refresh(envelope)
- envelope_data = convert_dm_envelope(envelope)
+ envelope_data = convert_dm_envelope(db, envelope)
# Broadcast reaction update to both participants
try:
diff --git a/backend/routes/profile.py b/backend/routes/profile.py
index 6e1cb52..31d1794 100644
--- a/backend/routes/profile.py
+++ b/backend/routes/profile.py
@@ -131,7 +131,7 @@ async def get_user_profile(
verified=current_user.verified,
suspended=current_user.suspended or False,
suspension_reason=current_user.suspension_reason,
- deleted=current_user.deleted or False,
+ deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted
)
@@ -160,7 +160,7 @@ async def list_users(
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
- deleted=user.deleted or False,
+ deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
).model_dump()
for user in users
]
@@ -275,6 +275,9 @@ async def get_user_by_username(
"""
Get user profile by username
"""
+ if not username or not is_valid_username(username):
+ raise HTTPException(status_code=400, detail="Invalid username format")
+
user = db.query(User).filter(User.username == username).first()
if not user:
@@ -282,6 +285,23 @@ async def get_user_by_username(
_ensure_owner_unsuspended(user, db)
+ # Handle deleted or suspended users
+ if user.deleted or user.suspended:
+ return UserProfileResponse(
+ id=user.id,
+ username="deleted",
+ display_name="Deleted User",
+ profile_picture=None,
+ bio=None,
+ online=False,
+ last_seen=None, # Clear last seen timestamp
+ created_at=None, # Clear member since timestamp
+ verified=False,
+ suspended=False,
+ suspension_reason=None,
+ deleted=True
+ )
+
return UserProfileResponse(
id=user.id,
username=user.username,
@@ -294,7 +314,7 @@ async def get_user_by_username(
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
- deleted=user.deleted or False,
+ deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted
)
@router.get("/user/id/{user_id}")
@@ -305,6 +325,9 @@ async def get_user_by_id(
"""
Get user profile by user ID
"""
+ if user_id <= 0:
+ raise HTTPException(status_code=400, detail="Invalid user ID")
+
user = db.query(User).filter(User.id == user_id).first()
if not user:
@@ -312,8 +335,8 @@ async def get_user_by_id(
_ensure_owner_unsuspended(user, db)
- # Handle deleted users
- if user.deleted:
+ # Handle deleted or suspended users
+ if user.deleted or user.suspended:
return UserProfileResponse(
id=user.id,
username="deleted",
diff --git a/backend/utils.py b/backend/utils.py
index 2d294dd..660b475 100644
--- a/backend/utils.py
+++ b/backend/utils.py
@@ -4,7 +4,7 @@ import jwt
from typing import Optional, Any
import bcrypt
-from constants import *
+from constants import ACCESS_TOKEN_EXPIRE_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
# JWT Helper Functions
def create_token(user_id: int, username: str, session_id: str) -> str:
diff --git a/frontend/index.html b/frontend/index.html
index 9038e1e..e0e69ad 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
Loading...
-
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7cb4c96..8cb4602 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,23 +1,25 @@
-import { BrowserRouter, Routes, Route, useNavigate, matchRoutes, type RouteObject } from "react-router-dom";
+import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
+import { AnimatePresence, motion } from "motion/react";
import { ElectronTitleBar } from "./Electron";
import { useAppState } from "./pages/chat/state";
-import { lazy, useEffect, useState } from "react";
+import { lazy, useEffect, useRef, useState } from "react";
import { parseProfileLink } from "./core/profileLinks";
import NotFoundPage from "./pages/not-found/NotFoundPage";
import ProtectedRoute from "./pages/ProtectedRoute";
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
+import { delay } from "./utils/utils";
// Lazy load route components
const HomePage = lazy(() => import("./pages/home/HomePage"));
-const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
-const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
+const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
const routeConfig: RouteObject[] = [
{ path: "/", element: },
- { path: "/login", element: },
- { path: "/register", element: },
+ { path: "/auth", element: },
+ { path: "/login", element: },
+ { path: "/register", element: },
{ path: "/download-app", element: },
{
path: "/chat",
@@ -66,6 +68,54 @@ function SmartCatchAll() {
}
}
+function AnimatedRoutes() {
+ const location = useLocation();
+ const prevPathnameRef = useRef(location.pathname);
+
+ return (
+
+ {
+ if (prevPathnameRef.current !== location.pathname) {
+ prevPathnameRef.current = location.pathname;
+ document.body.style.overflow = "hidden";
+ }
+ }}
+ onAnimationComplete={async () => {
+ await delay(500);
+ document.body.style.overflow = "";
+ }}
+ initial={{ opacity: 0, scale: 0.8 }}
+ animate={{ opacity: 1, scale: 1 }}
+ exit={{ opacity: 1, scale: 1.1 }}
+ transition={{
+ type: "spring",
+ stiffness: 300,
+ damping: 30,
+ mass: 0.8
+ }}
+ style={{
+ transformOrigin: "center center",
+ width: "100%",
+ height: "100%",
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0
+ }}
+ >
+
+ {routeConfig.map((route, index) => (
+
+ ))}
+
+
+
+ );
+}
+
export default function App() {
const { restoreUserFromStorage, user } = useAppState();
const [authReady, setAuthReady] = useState(false);
@@ -80,11 +130,7 @@ export default function App() {
-
- {routeConfig.map((route, index) => (
-
- ))}
-
+
{user.isSuspended && (
void;
leftIcon?: string | React.ReactNode;
rightIcon?: string | React.ReactNode;
+ containerRef: React.RefObject;
+ headerRef?: React.RefObject;
+ bottomAppBarRef?: React.RefObject;
}
export default function SearchBar({
@@ -21,9 +24,14 @@ export default function SearchBar({
isExpanded,
onToggleExpanded,
leftIcon = "search--outlined",
- rightIcon = null
+ rightIcon = null,
+ containerRef,
+ headerRef,
+ bottomAppBarRef
}: SearchBarProps) {
const [dynamicHeight, setDynamicHeight] = useState("48px");
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const [showResults, setShowResults] = useState(false);
const searchContainerRef = useRef(null);
const inputRef = useRef(null);
const parentContainerRef = useRef(null);
@@ -33,17 +41,46 @@ export default function SearchBar({
useEffect(() => {
if (isExpanded && inputRef.current) {
inputRef.current.focus();
- // Set expanded height
- const leftPanel = document.getElementById('chat-list');
- if (leftPanel) {
- const panelHeight = leftPanel.offsetHeight;
- setDynamicHeight(`${panelHeight}px`);
+ // Set expanded height, subtracting both header and bottom app bar heights
+ if (containerRef.current) {
+ const panelHeight = containerRef.current.offsetHeight;
+ let headerHeight = 0;
+ let bottomBarHeight = 0;
+
+ // Get header height
+ if (headerRef?.current) {
+ headerHeight = headerRef.current.offsetHeight;
+ }
+
+ // Get bottom app bar height
+ if (bottomAppBarRef?.current) {
+ bottomBarHeight = bottomAppBarRef.current.offsetHeight;
+ }
+
+ // Calculate height by subtracting both header and bottom bar heights
+ const availableHeight = panelHeight - headerHeight - bottomBarHeight;
+ setDynamicHeight(`${availableHeight}px`);
}
} else {
// Set collapsed height
setDynamicHeight("48px");
}
- }, [isExpanded]);
+
+ // Show/hide results and disable overflow during transition
+ if (isExpanded) {
+ setShowResults(true);
+ }
+
+ setIsTransitioning(true);
+ const timeout = setTimeout(() => {
+ setIsTransitioning(false);
+ if (!isExpanded) {
+ setShowResults(false);
+ }
+ }, 400); // Match transition duration (0.4s)
+
+ return () => clearTimeout(timeout);
+ }, [isExpanded, containerRef, headerRef, bottomAppBarRef]);
function handleToggle() {
onToggleExpanded();
@@ -107,9 +144,12 @@ export default function SearchBar({
- {/* Results Section - Only visible when expanded */}
- {isExpanded && (
-
+ {/* Results Section - Visible during expansion and collapse transition */}
+ {showResults && (
+
{children}
)}
diff --git a/frontend/src/core/components/css/searchBar.module.scss b/frontend/src/core/components/css/searchBar.module.scss
index a63d859..7f6f049 100644
--- a/frontend/src/core/components/css/searchBar.module.scss
+++ b/frontend/src/core/components/css/searchBar.module.scss
@@ -42,7 +42,7 @@ $font-size: 16px;
top: 0;
left: 0;
right: 0;
- bottom: 0;
+ // bottom will be set dynamically by React to account for bottom app bar
border-radius: 0;
background-color: $color-dark-surface-container;
// Height will be set dynamically by React
diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts
index bef62c9..a0760dd 100644
--- a/frontend/src/core/push-notifications/push-notifications.ts
+++ b/frontend/src/core/push-notifications/push-notifications.ts
@@ -3,6 +3,7 @@ import { isElectron } from "@/core/electron/electron";
import { websocket } from "@/core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
import serviceWorker from "./service-worker?worker&url";
+import logo from "@/images/logo.svg";
export interface PushSubscriptionData {
endpoint: string;
@@ -111,7 +112,7 @@ async function showMessageNotification(message: any): Promise
{
body: message.content.length > 100
? message.content.substring(0, 100) + "..."
: message.content,
- icon: message.profile_picture || "/logo.png",
+ icon: message.profile_picture || logo,
tag: `message_${message.id}`,
data: {
type: "public_message",
diff --git a/frontend/src/core/push-notifications/service-worker.ts b/frontend/src/core/push-notifications/service-worker.ts
index ff40735..b111c16 100644
--- a/frontend/src/core/push-notifications/service-worker.ts
+++ b/frontend/src/core/push-notifications/service-worker.ts
@@ -1,5 +1,7 @@
///
+import logo from "@/images/logo.svg";
+
declare const self: ServiceWorkerGlobalScope;
interface NotificationPayload {
@@ -36,8 +38,8 @@ self.addEventListener("push", function(event: ExtendableEvent) {
const options: NotificationOptions = {
body: data.body,
- icon: data.icon || "/logo.png",
- badge: "/logo.png",
+ icon: data.icon || logo,
+ badge: logo,
image: data.image,
tag: data.tag || "message",
data: data.data,
diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts
index 435a113..d4400c1 100644
--- a/frontend/src/core/websocket.ts
+++ b/frontend/src/core/websocket.ts
@@ -44,6 +44,18 @@ let globalMessageHandler: ((response: WebSocketMessage) => void) | null = n
*/
let callSignalingHandler: CallSignalingHandler | null = null;
+/**
+ * Reconnection state
+ */
+let reconnectAttempts = 0;
+const MAX_RECONNECT_DELAY = 30000; // 30 seconds max delay
+const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second
+let isReconnecting = false;
+let messageHandler: ((e: MessageEvent) => void) | null = null;
+let errorHandler: ((e: Event) => void) | null = null;
+let closeHandler: ((e: CloseEvent) => void) | null = null;
+let openHandler: ((e: Event) => void) | null = null;
+
/**
* Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages
@@ -60,97 +72,208 @@ export function setCallSignalingHandler(handler: CallSignalingHandler | null): v
callSignalingHandler = handler;
}
-export function request(payload: WebSocketMessage): Promise> {
- console.log("WebSocket request:", payload);
- return new Promise((resolve, reject) => {
- function requestInner() {
- let listener: ((e: MessageEvent) => void) | null = null;
- listener = (e) => {
- resolve(JSON.parse(e.data));
- websocket.removeEventListener("message", listener!);
+/**
+ * Clean up all event listeners from the current WebSocket instance
+ * @private
+ */
+function cleanupWebSocket(): void {
+ if (websocket) {
+ if (messageHandler) {
+ websocket.removeEventListener("message", messageHandler);
+ }
+ if (errorHandler) {
+ websocket.removeEventListener("error", errorHandler);
+ }
+ if (closeHandler) {
+ websocket.removeEventListener("close", closeHandler);
+ }
+ if (openHandler) {
+ websocket.removeEventListener("open", openHandler);
+ }
+
+ // Close if still connected
+ if (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING) {
+ try {
+ websocket.close();
+ } catch (e) {
+ // Ignore errors during cleanup
}
- websocket.addEventListener("message", listener);
- websocket.send(JSON.stringify(payload))
-
- setTimeout(() => reject("Request timed out"), 10000);
}
-
- if (websocket.readyState == 0) {
- websocket.addEventListener("open", requestInner);
- setTimeout(() => reject("Request timed out"), 10000);
- } else {
- requestInner();
- }
- })
+ }
}
/**
- * This function will wait 3 seconds and them attempts to reconnect the WebSocket.
- * If it fails, tries again in an endless loop until the connection is established
- * again.
- *
+ * Calculate exponential backoff delay
+ * @param attempt - Current reconnection attempt number
+ * @returns Delay in milliseconds
* @private
*/
-async function onError() {
- console.warn("WebSocket disconnected, retrying in 3 seconds...");
- await delay(3000);
- websocket = create();
+function getReconnectDelay(attempt: number): number {
+ const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attempt);
+ return Math.min(delay, MAX_RECONNECT_DELAY);
+}
- let listener: () => void | null;
- listener = () => {
- console.log("WebSocket successfully reconnected!");
- websocket.removeEventListener("open", listener);
+/**
+ * Handle WebSocket reconnection with exponential backoff
+ * @private
+ */
+async function reconnect(): Promise {
+ if (isReconnecting) {
+ return;
}
- websocket.addEventListener("open", listener);
- websocket.addEventListener("error", onError);
+ isReconnecting = true;
+
+ // Clean up old connection
+ cleanupWebSocket();
+
+ const delayMs = getReconnectDelay(reconnectAttempts);
+ reconnectAttempts++;
+
+ await delay(delayMs);
+
+ try {
+ websocket = create();
+ setupEventHandlers();
+ } catch (error) {
+ // If creation fails, try again
+ isReconnecting = false;
+ reconnect();
+ }
+}
+
+/**
+ * Setup event handlers for the WebSocket connection
+ * @private
+ */
+function setupEventHandlers(): void {
+ // Message handler
+ messageHandler = (e: MessageEvent) => {
+ try {
+ const response: WebSocketMessage = JSON.parse(e.data);
+
+ // Handle call signaling messages
+ if (callSignalingHandler && response.type === "call_signaling" && response.data) {
+ callSignalingHandler.handleWebSocketMessage(response.data);
+ }
+
+ // Handle status and typing messages
+ if (response.type === "statusUpdate") {
+ onlineStatusManager.handleStatusUpdate(response as any);
+ } else if (response.type === "typing") {
+ typingManager.handleTyping(response as any);
+ } else if (response.type === "stopTyping") {
+ typingManager.handleStopTyping(response as any);
+ } else if (response.type === "dmTyping") {
+ typingManager.handleDmTyping(response as any);
+ } else if (response.type === "stopDmTyping") {
+ typingManager.handleStopDmTyping(response as any);
+ } else if (response.type === "suspended") {
+ // Handle account suspension
+ const { setSuspended } = useAppState.getState();
+ const reason = response.data?.reason || "No reason provided";
+ setSuspended(reason);
+ // Close WebSocket connection
+ websocket.close();
+ } else if (response.type === "account_deleted") {
+ // Handle account deletion - silent logout
+ const { logout } = useAppState.getState();
+ logout();
+ // Close WebSocket connection
+ websocket.close();
+ }
+
+ // Route message to global handler if set
+ if (globalMessageHandler) {
+ globalMessageHandler(response);
+ }
+ } catch (error) {
+ console.error("Error parsing WebSocket message:", error);
+ }
+ };
+ websocket.addEventListener("message", messageHandler);
+
+ // Open handler
+ openHandler = () => {
+ reconnectAttempts = 0; // Reset on successful connection
+ isReconnecting = false;
+ };
+ websocket.addEventListener("open", openHandler);
+
+ // Error handler
+ errorHandler = () => {
+ // Don't reconnect immediately on error - let close handler handle it
+ // This prevents double reconnection attempts
+ };
+ websocket.addEventListener("error", errorHandler);
+
+ // Close handler
+ closeHandler = (e: CloseEvent) => {
+ // Don't reconnect if it was a clean close (e.g., logout, suspension)
+ if (e.code === 1000 || e.code === 1001) {
+ return;
+ }
+
+ // Reconnect for unexpected closes
+ if (!isReconnecting) {
+ reconnect();
+ }
+ };
+ websocket.addEventListener("close", closeHandler);
+}
+
+export function request(payload: WebSocketMessage): Promise> {
+ console.log("WebSocket request:", payload);
+ return new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ reject(new Error("Request timed out"));
+ }, 10000);
+
+ function requestInner() {
+ if (websocket.readyState !== WebSocket.OPEN) {
+ clearTimeout(timeoutId);
+ reject(new Error("WebSocket is not open"));
+ return;
+ }
+
+ const listener = (e: MessageEvent) => {
+ clearTimeout(timeoutId);
+ try {
+ resolve(JSON.parse(e.data));
+ } catch (error) {
+ reject(error);
+ }
+ websocket.removeEventListener("message", listener);
+ };
+
+ websocket.addEventListener("message", listener);
+
+ try {
+ websocket.send(JSON.stringify(payload));
+ } catch (error) {
+ clearTimeout(timeoutId);
+ websocket.removeEventListener("message", listener);
+ reject(error);
+ }
+ }
+
+ if (websocket.readyState === WebSocket.CONNECTING) {
+ const openListener = () => {
+ websocket.removeEventListener("open", openListener);
+ requestInner();
+ };
+ websocket.addEventListener("open", openListener);
+ } else if (websocket.readyState === WebSocket.OPEN) {
+ requestInner();
+ } else {
+ clearTimeout(timeoutId);
+ reject(new Error("WebSocket is closed"));
+ }
+ });
}
// --------------
// Initialization
// --------------
-websocket.addEventListener("message", (e) => {
- try {
- const response: WebSocketMessage = JSON.parse(e.data);
-
- // Handle call signaling messages
- if (callSignalingHandler && response.type === "call_signaling" && response.data) {
- callSignalingHandler.handleWebSocketMessage(response.data);
- }
-
- // Handle status and typing messages
- if (response.type === "statusUpdate") {
- onlineStatusManager.handleStatusUpdate(response as any);
- } else if (response.type === "typing") {
- typingManager.handleTyping(response as any);
- } else if (response.type === "stopTyping") {
- typingManager.handleStopTyping(response as any);
- } else if (response.type === "dmTyping") {
- typingManager.handleDmTyping(response as any);
- } else if (response.type === "stopDmTyping") {
- typingManager.handleStopDmTyping(response as any);
- } else if (response.type === "suspended") {
- // Handle account suspension
- const { setSuspended } = useAppState.getState();
- const reason = response.data?.reason || "No reason provided";
- setSuspended(reason);
- // Close WebSocket connection
- websocket.close();
- } else if (response.type === "account_deleted") {
- // Handle account deletion - silent logout
- const { logout } = useAppState.getState();
- logout();
- // Close WebSocket connection
- websocket.close();
- }
-
- // Route message to global handler if set
- if (globalMessageHandler) {
- globalMessageHandler(response);
- }
- } catch (error) {
- console.error("Error parsing WebSocket message:", error);
- }
-});
-websocket.addEventListener("error", onError);
\ No newline at end of file
+setupEventHandlers();
\ No newline at end of file
diff --git a/frontend/src/css/_components.scss b/frontend/src/css/_components.scss
index d7a4604..0f066b9 100644
--- a/frontend/src/css/_components.scss
+++ b/frontend/src/css/_components.scss
@@ -1,26 +1,6 @@
@use "material" as *;
@use "sass:color";
-.text-center {
- text-align: center;
-}
-
-.alert {
- padding: 0.8rem 1rem;
- border-radius: 6px;
- margin-bottom: 1rem;
-
- &.alert-success {
- background-color: #C6F6D5;
- color: #22543D;
- }
-
- &.alert-danger {
- background-color: #FED7D7;
- color: #742A2A;
- }
-}
-
.link {
color: $color-dark-primary;
font-weight: 600;
@@ -30,27 +10,6 @@ button, input {
font: inherit;
}
-// Dialog content styles
-.dialog-content {
- h3 {
- margin: 0 0 1rem 0;
- color: $color-dark-on-surface;
- font-size: 1.2rem;
- font-weight: 600;
- }
-
- mdui-text-field {
- width: 100%;
- }
-
- .dialog-actions {
- display: flex;
- gap: 0.75rem;
- justify-content: flex-end;
- margin-top: 1rem;
- }
-}
-
.rich-text-area {
width: 100%;
resize: none;
@@ -83,33 +42,6 @@ button, input {
}
}
-// Verified badge styles
-.verified-badge {
- display: inline-flex;
- align-items: center;
- color: $color-dark-primary;
- vertical-align: middle;
- user-select: none;
-
- &.small {
- font-size: 14px;
- width: 14px;
- height: 14px;
- }
-
- &.medium {
- font-size: 18px;
- width: 18px;
- height: 18px;
- }
-
- &.large {
- font-size: 24px;
- width: 24px;
- height: 24px;
- }
-}
-
// Status badge styles (unified for verified and warning)
.status-badge {
display: inline-flex;
@@ -121,7 +53,7 @@ button, input {
}
&.warning {
- color: #ff9800; // Orange color for warnings
+ color: $color-dark-tertiary; // Purple-themed warning color
}
&.small mdui-icon {
@@ -143,13 +75,6 @@ button, input {
}
}
-// Profile dialog specific styles
-.username-with-badge {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
.similarity-warning {
display: flex;
align-items: center;
diff --git a/frontend/src/css/_material.scss b/frontend/src/css/_material.scss
index ae8241c..b9c53c2 100644
--- a/frontend/src/css/_material.scss
+++ b/frontend/src/css/_material.scss
@@ -1,63 +1,59 @@
@use "sass:color";
// Dark
-$color-dark-primary: rgb(145 206 244);
-$color-dark-surface-tint: rgb(145 206 244);
-$color-dark-on-primary: rgb(0 52 74);
-$color-dark-primary-container: rgb(0 76 106);
-$color-dark-on-primary-container: rgb(197 231 255);
-$color-dark-secondary: rgb(182 201 216);
-$color-dark-on-secondary: rgb(32 51 62);
-$color-dark-secondary-container: rgb(55 73 85);
-$color-dark-on-secondary-container: rgb(210 229 244);
-$color-dark-tertiary: rgb(203 193 233);
-$color-dark-on-tertiary: rgb(51 44 76);
-$color-dark-tertiary-container: rgb(73 66 99);
-$color-dark-on-tertiary-container: rgb(231 222 255);
+// Generated from base color #9333EA (rgb(147, 51, 234))
+$color-dark-primary: rgb(219 185 249);
+$color-dark-surface-tint: rgb(219 185 249);
+$color-dark-on-primary: rgb(62 36 88);
+$color-dark-primary-container: rgb(86 59 113);
+$color-dark-on-primary-container: rgb(240 219 255);
+$color-dark-secondary: rgb(208 193 218);
+$color-dark-on-secondary: rgb(54 44 63);
+$color-dark-secondary-container: rgb(77 67 86);
+$color-dark-on-secondary-container: rgb(237 221 246);
+$color-dark-tertiary: rgb(243 183 190);
+$color-dark-on-tertiary: rgb(75 37 43);
+$color-dark-tertiary-container: rgb(101 58 64);
+$color-dark-on-tertiary-container: rgb(255 217 221);
$color-dark-error: rgb(255 180 171);
$color-dark-on-error: rgb(105 0 5);
$color-dark-error-container: rgb(147 0 10);
$color-dark-on-error-container: rgb(255 218 214);
-$color-dark-background: rgb(15 20 23);
-$color-dark-on-background: rgb(223 227 231);
-$color-dark-surface: rgb(15 20 23);
-$color-dark-on-surface: rgb(223 227 231);
-$color-dark-surface-variant: rgb(65 72 77);
-$color-dark-on-surface-variant: rgb(193 199 206);
-$color-dark-outline: rgb(139 146 151);
-$color-dark-outline-variant: rgb(65 72 77);
+$color-dark-background: rgb(21 18 24);
+$color-dark-on-background: rgb(232 224 232);
+$color-dark-surface: rgb(21 18 24);
+$color-dark-on-surface: rgb(232 224 232);
+$color-dark-surface-variant: rgb(74 69 78);
+$color-dark-on-surface-variant: rgb(204 196 206);
+$color-dark-outline: rgb(150 142 152);
+$color-dark-outline-variant: rgb(74 69 78);
$color-dark-shadow: rgb(0 0 0);
$color-dark-scrim: rgb(0 0 0);
-$color-dark-inverse-surface: rgb(223 227 231);
-$color-dark-inverse-on-surface: rgb(44 49 52);
-$color-dark-inverse-primary: rgb(31 101 134);
-$color-dark-primary-fixed: rgb(197 231 255);
-$color-dark-on-primary-fixed: rgb(0 30 45);
-$color-dark-primary-fixed-dim: rgb(145 206 244);
-$color-dark-on-primary-fixed-variant: rgb(0 76 106);
-$color-dark-secondary-fixed: rgb(210 229 244);
-$color-dark-on-secondary-fixed: rgb(10 30 40);
-$color-dark-secondary-fixed-dim: rgb(182 201 216);
-$color-dark-on-secondary-fixed-variant: rgb(55 73 85);
-$color-dark-tertiary-fixed: rgb(231 222 255);
-$color-dark-on-tertiary-fixed: rgb(29 23 53);
-$color-dark-tertiary-fixed-dim: rgb(203 193 233);
-$color-dark-on-tertiary-fixed-variant: rgb(73 66 99);
-$color-dark-surface-dim: rgb(15 20 23);
-$color-dark-surface-bright: rgb(53 58 61);
-$color-dark-surface-container-lowest: rgb(10 15 18);
-$color-dark-surface-container-low: rgb(24 28 31);
-$color-dark-surface-container: rgb(28 32 36);
-$color-dark-surface-container-high: rgb(38 43 46);
-$color-dark-surface-container-highest: rgb(49 53 57);
+$color-dark-inverse-surface: rgb(232 224 232);
+$color-dark-inverse-on-surface: rgb(51 47 53);
+$color-dark-inverse-primary: rgb(111 82 138);
+$color-dark-primary-fixed: rgb(240 219 255);
+$color-dark-on-primary-fixed: rgb(40 13 66);
+$color-dark-primary-fixed-dim: rgb(219 185 249);
+$color-dark-on-primary-fixed-variant: rgb(86 59 113);
+$color-dark-secondary-fixed: rgb(237 221 246);
+$color-dark-on-secondary-fixed: rgb(33 24 41);
+$color-dark-secondary-fixed-dim: rgb(208 193 218);
+$color-dark-on-secondary-fixed-variant: rgb(77 67 86);
+$color-dark-tertiary-fixed: rgb(255 217 221);
+$color-dark-on-tertiary-fixed: rgb(50 16 22);
+$color-dark-tertiary-fixed-dim: rgb(243 183 190);
+$color-dark-on-tertiary-fixed-variant: rgb(101 58 64);
+$color-dark-surface-dim: rgb(21 18 24);
+$color-dark-surface-bright: rgb(60 56 62);
+$color-dark-surface-container-lowest: rgb(16 13 18);
+$color-dark-surface-container-low: rgb(30 26 32);
+$color-dark-surface-container: rgb(34 30 36);
+$color-dark-surface-container-high: rgb(44 41 46);
+$color-dark-surface-container-highest: rgb(55 51 57);
$color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%);
$color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%);
-// custom colors
-$color-1: rgb(82, 109, 246);
-$color-2: rgb(65, 11, 113);
-$color-4: rgb(95, 26, 198);
-$color-3: rgb(49, 71, 179);
// Light
$color-light-primary: rgb(31 101 134);
$color-light-surface-tint: rgb(31 101 134);
diff --git a/frontend/src/images/logo.png b/frontend/src/images/logo.png
deleted file mode 100644
index 4eade18..0000000
Binary files a/frontend/src/images/logo.png and /dev/null differ
diff --git a/frontend/src/images/logo.svg b/frontend/src/images/logo.svg
new file mode 100644
index 0000000..c2cd2f0
--- /dev/null
+++ b/frontend/src/images/logo.svg
@@ -0,0 +1,2297 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/auth/Auth.tsx b/frontend/src/pages/auth/Auth.tsx
index 4a24ebd..b95a161 100644
--- a/frontend/src/pages/auth/Auth.tsx
+++ b/frontend/src/pages/auth/Auth.tsx
@@ -1,12 +1,30 @@
import type React from "react";
+import { motion, AnimatePresence } from "motion/react";
import styles from "./auth.module.scss";
export function AuthContainer({ children }: { children?: React.ReactNode }) {
return (
)
}
@@ -29,13 +47,63 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
const iconName = typeof icon == "string" ? icon : icon.name;
return (
-
+
- {iconName}
+
+ {iconName}
+
{title}
- {subtitle}
-
+
+ {subtitle}
+
+
)
}
@@ -47,11 +115,40 @@ export interface Alert {
}
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
+ const displayAlerts = alerts.slice(-3);
+
return (
-
- {alerts.slice(-3).map((alert, i) => {
- return
{alert.message}
- })}
+
+
+ {displayAlerts.map((alert, i) => (
+
+ {alert.message}
+
+ ))}
+
)
}
\ No newline at end of file
diff --git a/frontend/src/pages/auth/AuthPage.tsx b/frontend/src/pages/auth/AuthPage.tsx
new file mode 100644
index 0000000..ae5f888
--- /dev/null
+++ b/frontend/src/pages/auth/AuthPage.tsx
@@ -0,0 +1,164 @@
+import { AuthContainer } from "./Auth";
+import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { motion, AnimatePresence } from "motion/react";
+import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
+import { LoginForm } from "./LoginForm";
+import { RegisterForm } from "./RegisterForm";
+import type { Variants, Transition } from "motion/react";
+import styles from "./auth.module.scss";
+
+const slideVariants: Variants = {
+ enter: (direction: number) => ({
+ x: direction > 0 ? 300 : -300,
+ opacity: 0
+ }),
+ center: {
+ x: 0,
+ opacity: 1
+ },
+ exit: (direction: number) => ({
+ x: direction > 0 ? -300 : 300,
+ opacity: 0
+ })
+};
+
+const slideTransition: Transition = {
+ x: {
+ type: "spring",
+ stiffness: 300,
+ damping: 30
+ },
+ opacity: { duration: 0.2 }
+};
+
+
+
+export default function AuthPage() {
+ const [searchParams] = useSearchParams();
+ const { navigate: navigateDownloadApp } = useDownloadAppScreen();
+ if (navigateDownloadApp) return navigateDownloadApp;
+ const navigate = useNavigate();
+
+ const [direction, setDirection] = useState(0);
+ const prevMode = useRef(searchParams.get("mode") || "login");
+ const containerRef = useRef
(null);
+ const loginFormRef = useRef(null);
+ const registerFormRef = useRef(null);
+ const [containerHeight, setContainerHeight] = useState("auto");
+ const currentMode = searchParams.get("mode") || "login";
+ const enteringElementRef = useRef<"login" | "register" | null>(null);
+
+ useEffect(() => {
+ if (prevMode.current !== currentMode) {
+ setDirection(currentMode === "register" ? 1 : -1);
+ prevMode.current = currentMode;
+ enteringElementRef.current = currentMode as "login" | "register";
+ }
+ }, [currentMode]);
+
+ const measureActiveHeight = useCallback(() => {
+ const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current;
+ if (activeComponent) {
+ const height = activeComponent.scrollHeight;
+ if (height > 0) {
+ setContainerHeight(height);
+ }
+ }
+ }, [currentMode, loginFormRef, registerFormRef]);
+
+ useLayoutEffect(() => {
+ // Always measure, but prioritize the entering element during transitions
+ // Use double requestAnimationFrame to ensure DOM is fully updated and layout is complete
+ let rafId2: number | null = null;
+ const rafId1 = requestAnimationFrame(() => {
+ rafId2 = requestAnimationFrame(() => {
+ measureActiveHeight();
+ });
+ });
+
+ return () => {
+ cancelAnimationFrame(rafId1);
+ if (rafId2 !== null) {
+ cancelAnimationFrame(rafId2);
+ }
+ };
+ }, [currentMode]);
+
+ function switchMode(newMode: "login" | "register") {
+ navigate(`/auth?mode=${newMode}`, { replace: true });
+ }
+
+ function handleAnimationComplete(
+ currentMode: "login" | "register",
+ mode: "login" | "register",
+ enteringElementRef: RefObject<"login" | "register" | null>,
+ formRef: React.RefObject,
+ setContainerHeight: (height: number) => void
+ ) {
+ return () => {
+ if (currentMode === mode && enteringElementRef.current === mode) {
+ enteringElementRef.current = null;
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (formRef.current && currentMode === mode) {
+ const height = formRef.current.scrollHeight;
+ if (height > 0) {
+ setContainerHeight(height);
+ }
+ }
+ });
+ });
+ }
+ }
+ }
+
+ return (
+
+
+
+ {currentMode === "login" ? (
+
+ switchMode("register")} />
+
+ ) : (
+
+ switchMode("login")} />
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/auth/AuthTextField.tsx b/frontend/src/pages/auth/AuthTextField.tsx
new file mode 100644
index 0000000..6b6f8fe
--- /dev/null
+++ b/frontend/src/pages/auth/AuthTextField.tsx
@@ -0,0 +1,138 @@
+import { forwardRef, useImperativeHandle, useRef, useState, useEffect } from "react";
+import { motion } from "motion/react";
+import styles from "./auth.module.scss";
+
+export interface AuthTextFieldHandle {
+ value: string;
+ focus: () => void;
+ blur: () => void;
+}
+
+export interface AuthTextFieldProps {
+ label: string;
+ name?: string;
+ type?: string;
+ icon?: string;
+ autocomplete?: string;
+ required?: boolean;
+ maxlength?: number;
+ counter?: boolean;
+ "toggle-password"?: boolean;
+ defaultValue?: string;
+ value?: string;
+ onChange?: (value: string) => void;
+ className?: string;
+}
+
+export const AuthTextField = forwardRef(
+ ({
+ label,
+ name,
+ type = "text",
+ icon,
+ autocomplete,
+ required = false,
+ maxlength,
+ counter = false,
+ "toggle-password": togglePassword = false,
+ defaultValue = "",
+ value: controlledValue,
+ onChange,
+ className = ""
+ }, ref) => {
+ const [internalValue, setInternalValue] = useState(defaultValue);
+ const [isFocused, setIsFocused] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+ const [charCount, setCharCount] = useState(0);
+ const inputRef = useRef(null);
+
+ const isControlled = controlledValue !== undefined;
+ const value = isControlled ? controlledValue : internalValue;
+ const displayType = togglePassword && type === "password" ? (showPassword ? "text" : "password") : type;
+
+ useEffect(() => {
+ if (!isControlled) {
+ setInternalValue(defaultValue);
+ }
+ }, [defaultValue, isControlled]);
+
+ useEffect(() => {
+ setCharCount(value.length);
+ }, [value]);
+
+ useImperativeHandle(ref, () => ({
+ get value() {
+ return value;
+ },
+ focus: () => {
+ inputRef.current?.focus();
+ },
+ blur: () => {
+ inputRef.current?.blur();
+ }
+ }));
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ if (!isControlled) {
+ setInternalValue(newValue);
+ }
+ onChange?.(newValue);
+ };
+
+ const hasError = false; // Can be extended for validation
+
+ return (
+
+
+ {icon && (
+
+ {icon.replace("--filled", "").replace("--outlined", "")}
+
+ )}
+
+ setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ autoComplete={autocomplete}
+ required={required}
+ maxLength={maxlength}
+ placeholder={label + (required ? " *" : "")}
+ className={styles.input}
+ />
+
+ {togglePassword && type === "password" && (
+
+ )}
+
+ {counter && maxlength && (
+
+ {charCount} / {maxlength}
+
+ )}
+
+ );
+ }
+);
+
+AuthTextField.displayName = "AuthTextField";
diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx
new file mode 100644
index 0000000..be6b091
--- /dev/null
+++ b/frontend/src/pages/auth/LoginForm.tsx
@@ -0,0 +1,220 @@
+import { useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { motion, type Transition, type Variants } from "motion/react";
+import { useImmer } from "use-immer";
+import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types";
+import { API_BASE_URL } from "@/core/config";
+import { useAppState } from "@/pages/chat/state";
+import { MaterialButton } from "@/utils/material";
+import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
+import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
+import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
+import { isElectron } from "@/core/electron/electron";
+import type { Alert, AlertType } from "./Auth";
+import { AuthHeader, AlertsContainer } from "./Auth";
+import styles from "./auth.module.scss";
+
+const loginFieldVariants: Variants = {
+ initial: {
+ opacity: 0,
+ y: 10
+ },
+ animate: {
+ opacity: 1,
+ y: 0
+ }
+};
+
+const loginFieldTransition: Transition = {
+ duration: 0.3,
+ ease: "easeInOut"
+};
+
+const loginButtonVariants: Variants = {
+ initial: {
+ opacity: 0,
+ y: 10
+ },
+ animate: {
+ opacity: 1,
+ y: 0
+ }
+};
+
+const loginButtonTransition: Transition = {
+ duration: 0.3,
+ delay: 0.4,
+ ease: "easeInOut"
+};
+
+interface LoginFormProps {
+ onSwitchMode: () => void;
+}
+
+export function LoginForm({ onSwitchMode }: LoginFormProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [alerts, updateAlerts] = useImmer([]);
+ const setUser = useAppState(state => state.setUser);
+ const navigate = useNavigate();
+
+ function showAlert(type: AlertType, message: string) {
+ updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
+ }
+
+ const usernameElement = useRef(null);
+ const passwordElement = useRef(null);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+
+ if (isLoading) return;
+
+ const username = usernameElement.current!.value.trim();
+ const password = passwordElement.current!.value.trim();
+
+ if (!username || !password) {
+ showAlert("danger", "Пожалуйста, заполните все поля");
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const derived = await deriveAuthSecret(username, password);
+ const request: LoginRequest = {
+ username: username,
+ password: derived
+ }
+
+ const response = await fetch(`${API_BASE_URL}/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(request)
+ });
+
+ if (response.ok) {
+ const data: LoginResponse = await response.json();
+ setUser(data.token, data.user);
+
+ try {
+ await ensureKeysOnLogin(password, data.token);
+ } catch (e) {
+ console.error("Key setup failed:", e);
+ }
+
+ navigate("/chat");
+
+ try {
+ if (isSupported()) {
+ const initialized = await initialize();
+ if (initialized) {
+ await subscribe(data.token);
+
+ if (isElectron) {
+ await startElectronReceiver();
+ }
+
+ console.log("Notifications enabled");
+ } else {
+ console.log("Notification permission denied");
+ }
+ } else {
+ console.log("Notifications not supported");
+ }
+ } catch (e) {
+ console.error("Notification setup failed:", e);
+ }
+ } else {
+ const data: ErrorResponse = await response.json();
+
+ if (response.status === 403 && response.headers.get("suspension_reason")) {
+ const suspensionReason = response.headers.get("suspension_reason");
+ const setSuspended = useAppState.getState().setSuspended;
+ setSuspended(suspensionReason || "No reason provided");
+ return;
+ }
+
+ showAlert("danger", data.message || "Неверное имя пользователя или пароль");
+ }
+ } catch (error) {
+ showAlert("danger", "Ошибка соединения с сервером");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx
deleted file mode 100644
index 15e8c60..0000000
--- a/frontend/src/pages/auth/LoginPage.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import { useImmer } from "use-immer";
-import { AlertsContainer, type Alert, type AlertType } from "./Auth";
-import { AuthContainer, AuthHeader } from "./Auth";
-import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types";
-import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
-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 { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
-import { isElectron } from "@/core/electron/electron";
-import { useNavigate } from "react-router-dom";
-import styles from "./auth.module.scss";
-import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
-import { MaterialButton, MaterialTextField } from "@/utils/material";
-
-export default function LoginPage() {
- const [alerts, updateAlerts] = useImmer([]);
- const setUser = useAppState(state => state.setUser);
- const navigate = useNavigate();
- const { navigate: navigateDownloadApp } = useDownloadAppScreen();
- if (navigateDownloadApp) return navigateDownloadApp;
-
- function showAlert(type: AlertType, message: string) {
- updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
- }
-
- const usernameElement = useRef(null);
- const passwordElement = useRef(null);
-
- return (
-
-
-
-
- )
-}
diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx
new file mode 100644
index 0000000..b3e5c02
--- /dev/null
+++ b/frontend/src/pages/auth/RegisterForm.tsx
@@ -0,0 +1,249 @@
+import { useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { motion, type Transition, type Variants } from "motion/react";
+import { useImmer } from "use-immer";
+import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
+import { API_BASE_URL } from "@/core/config";
+import { useAppState } from "@/pages/chat/state";
+import { MaterialButton, MaterialIconButton } from "@/utils/material";
+import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
+import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
+import type { Alert, AlertType } from "./Auth";
+import { AuthHeader, AlertsContainer } from "./Auth";
+import styles from "./auth.module.scss";
+
+const registerFieldVariants: Variants = {
+ initial: {
+ opacity: 0,
+ y: 10
+ },
+ animate: {
+ opacity: 1,
+ y: 0
+ }
+};
+
+const registerFieldTransition: Transition = {
+ duration: 0.3,
+ ease: "easeInOut"
+};
+
+const registerButtonVariants: Variants = {
+ initial: {
+ opacity: 0,
+ y: 10
+ },
+ animate: {
+ opacity: 1,
+ y: 0
+ }
+};
+
+const registerButtonTransition: Transition = {
+ duration: 0.3,
+ delay: 0.6,
+ ease: "easeInOut"
+};
+
+interface RegisterFormProps {
+ onSwitchMode: () => void;
+}
+
+export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [alerts, updateAlerts] = useImmer([]);
+ const setUser = useAppState(state => state.setUser);
+ const navigate = useNavigate();
+
+ function showAlert(type: AlertType, message: string) {
+ updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
+ }
+
+ const displayNameElement = useRef(null);
+ const usernameElement = useRef(null);
+ const passwordElement = useRef(null);
+ const confirmPasswordElement = useRef(null);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+
+ if (isLoading) return;
+
+ const displayName = displayNameElement.current!.value.trim();
+ const username = usernameElement.current!.value.trim();
+ const password = passwordElement.current!.value.trim();
+ const confirmPassword = confirmPasswordElement.current!.value.trim();
+
+ if (!displayName || !username || !password || !confirmPassword) {
+ showAlert("danger", "Пожалуйста, заполните все поля");
+ return;
+ }
+
+ if (password !== confirmPassword) {
+ showAlert("danger", "Пароли не совпадают");
+ return;
+ }
+
+ if (displayName.length < 1 || displayName.length > 64) {
+ showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов");
+ return;
+ }
+
+ if (username.length < 3 || username.length > 20) {
+ showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
+ return;
+ }
+
+ if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
+ showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания");
+ return;
+ }
+
+ if (password.length < 5 || password.length > 50) {
+ showAlert("danger", "Пароль должен быть от 5 до 50 символов");
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const derived = await deriveAuthSecret(username, password);
+ const request: RegisterRequest = {
+ display_name: displayName,
+ username: username,
+ password: derived,
+ confirm_password: derived
+ }
+
+ const response = await fetch(`${API_BASE_URL}/register`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(request)
+ });
+
+ if (response.ok) {
+ const data: LoginResponse = await response.json();
+ setUser(data.token, data.user);
+
+ try {
+ await ensureKeysOnLogin(password, data.token);
+ } catch (e) {
+ console.error("Key setup failed:", e);
+ }
+
+ navigate("/chat");
+ } else {
+ const data: ErrorResponse = await response.json();
+ showAlert("danger", data.message || "Ошибка при регистрации");
+ }
+ } catch (error) {
+ showAlert("danger", "Ошибка соединения с сервером");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? "Регистрация..." : "Зарегистрироваться"}
+
+
+
+
+
+
+ >
+ );
+}
+
diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx
deleted file mode 100644
index 991ec68..0000000
--- a/frontend/src/pages/auth/RegisterPage.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { useImmer } from "use-immer";
-import { AuthContainer, AuthHeader } from "./Auth";
-import { AlertsContainer, type Alert, type AlertType } from "./Auth";
-import { useRef } from "react";
-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 { MaterialButton, MaterialTextField } from "@/utils/material";
-import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
-import { useNavigate } from "react-router-dom";
-import styles from "./auth.module.scss";
-import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
-
-export default function RegisterPage() {
- const [alerts, updateAlerts] = useImmer([]);
- const setUser = useAppState(state => state.setUser);
- const navigate = useNavigate();
- const { navigate: navigateDownloadApp } = useDownloadAppScreen();
- if (navigateDownloadApp) return navigateDownloadApp;
-
- function showAlert(type: AlertType, message: string) {
- updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
- }
-
- const displayNameElement = useRef(null);
- const usernameElement = useRef(null);
- const passwordElement = useRef(null);
- const confirmPasswordElement = useRef(null);
-
- return (
-
-
-
-
- )
-}
diff --git a/frontend/src/pages/auth/auth.module.scss b/frontend/src/pages/auth/auth.module.scss
index ca1560f..846bdbb 100644
--- a/frontend/src/pages/auth/auth.module.scss
+++ b/frontend/src/pages/auth/auth.module.scss
@@ -1,52 +1,321 @@
+@use "sass:color";
@use "../../css/colors" as *;
@use "../../css/material" as *;
+@keyframes rotateGradient {
+ from {
+ transform: translate(-50%, -50%) rotate(0deg);
+ }
+ to {
+ transform: translate(-50%, -50%) rotate(360deg);
+ }
+}
+
+@keyframes slideInDown {
+ from {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes shake {
+ 0%, 100% {
+ transform: translateX(0);
+ }
+ 10%, 30%, 50%, 70%, 90% {
+ transform: translateX(-4px);
+ }
+ 20%, 40%, 60%, 80% {
+ transform: translateX(4px);
+ }
+}
+
.authContainer {
display: flex;
justify-content: center;
align-items: center;
- height: 100%;
+ min-height: 100vh;
+ width: 100vw;
padding: 2rem;
- background-color: $color-dark-surface;
+ position: fixed;
+ top: 0;
+ left: 0;
+ overflow: hidden;
+ background: $color-dark-surface;
+ .gradientBackground {
+ $size: 550px;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: $size;
+ height: $size;
+ background: conic-gradient(
+ from 0deg,
+ rgba(147, 51, 234, 0.5) 0%,
+ rgba(99, 102, 241, 0.6) 12.5%,
+ rgba(59, 130, 246, 0.55) 25%,
+ rgba(168, 85, 247, 0.5) 37.5%,
+ rgba(217, 70, 239, 0.6) 50%,
+ rgba(236, 72, 153, 0.55) 62.5%,
+ rgba(192, 132, 252, 0.5) 75%,
+ rgba(126, 34, 206, 0.6) 87.5%,
+ rgba(147, 51, 234, 0.5) 100%
+ );
+ animation: rotateGradient 8s linear infinite;
+ border-radius: 50%;
+ filter: blur(80px);
+ z-index: 0;
+ will-change: transform;
+ backface-visibility: hidden;
+ }
+
.authCard {
- background-color: $color-dark-surface-container;
+ background: rgba($color-dark-surface-container, 0.7);
+ backdrop-filter: blur(20px);
color: $color-dark-on-surface;
- border-radius: 12px;
- box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+ border-radius: 24px;
+ border: 1px solid rgba($color-dark-outline, 0.1);
+ box-shadow:
+ 0 20px 60px rgba(0, 0, 0, 0.3),
+ 0 0 0 1px rgba($color-dark-primary, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
width: 100%;
max-width: 450px;
overflow: hidden;
- animation: authCardAnimation 0.3s ease-in-out;
- }
+ position: relative;
+ z-index: 1;
- .authHeader {
- margin: 0;
- padding: 16px;
- padding-bottom: 0;
- text-align: center;
+ .formWrapper {
+ position: absolute;
+ width: 100%;
+ top: 0;
+ left: 0;
- h2 {
- font-size: 1.8rem;
- margin: 0;
- margin-bottom: 0.5rem;
- align-items: center;
- display: flex;
- flex-direction: row;
- gap: 10px;
- justify-content: center;
- }
- }
-
- .authBody {
- padding: 25px;
- padding-bottom: 16px;
-
- form {
- display: flex;
- flex-direction: column;
- gap: 10px;
+ .authHeader {
+ margin: 0;
+ padding: 24px;
+ padding-bottom: 8px;
+ text-align: center;
+
+ h2 {
+ font-size: 1.8rem;
+ margin: 0;
+ margin-bottom: 0.5rem;
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ justify-content: center;
+ font-weight: 600;
+
+ .material-symbols {
+ color: $color-dark-primary;
+ filter: drop-shadow(0 0 8px rgba($color-dark-primary, 0.4));
+ }
+ }
+
+ p {
+ color: $color-dark-on-surface-variant;
+ font-size: 0.95rem;
+ margin: 0;
+ }
+ }
+
+ .authBody {
+ padding: 24px;
+ padding-bottom: 20px;
+
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ .authButtons {
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+ }
+ }
+
+ .registerLink {
+ text-align: center;
+ margin-top: 16px;
+ font-size: 0.9rem;
+ color: $color-dark-on-surface-variant;
+
+ a {
+ color: $color-dark-primary;
+ margin-inline-start: 3px;
+ }
+ }
+ }
}
}
}
+// AuthTextField Styles
+.authTextField {
+ position: relative;
+ width: 100%;
+
+ .fieldContainer {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: rgba($color-dark-surface-variant, 0.3);
+ border: 1px solid rgba($color-dark-outline, 0.2);
+ border-radius: 12px;
+ padding: 0 0 0 12px;
+ transition: all 0.3s ease;
+ min-height: 44px;
+
+ &:hover {
+ border-color: rgba($color-dark-outline, 0.4);
+ background: rgba($color-dark-surface-variant, 0.4);
+ }
+
+ &.focused {
+ border-color: $color-dark-primary;
+ background: rgba($color-dark-surface-variant, 0.5);
+ box-shadow:
+ 0 0 0 4px rgba($color-dark-primary, 0.1),
+ 0 4px 12px rgba($color-dark-primary, 0.2);
+ }
+
+ &.error {
+ border-color: $color-dark-error;
+ animation: shake 0.4s ease;
+
+ &.focused {
+ box-shadow:
+ 0 0 0 4px rgba($color-dark-error, 0.1),
+ 0 4px 12px rgba($color-dark-error, 0.2);
+ }
+ }
+
+ &.noIcon {
+ gap: 0;
+
+ .inputWrapper {
+ margin-left: 0;
+ }
+ }
+
+ &.hasToggle {
+ padding-right: 12px;
+ }
+ }
+
+ .fieldIcon {
+ color: $color-dark-on-surface-variant;
+ font-size: 18px;
+ flex-shrink: 0;
+ transition: color 0.3s ease;
+
+ .fieldContainer.focused & {
+ color: $color-dark-primary;
+ }
+ }
+
+ .inputWrapper {
+ position: relative;
+ flex: 1;
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ }
+
+ .input {
+ width: 100%;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: $color-dark-on-surface;
+ font-size: 0.95rem;
+ font-family: inherit;
+ padding: 12px 0 12px 0;
+ line-height: 1.4;
+ height: auto;
+ min-height: 20px;
+
+ &::placeholder {
+ color: $color-dark-on-surface-variant;
+ opacity: 0.7;
+ }
+
+ &:focus::placeholder {
+ opacity: 0.5;
+ }
+ }
+
+ .togglePassword {
+ background: none;
+ border: none;
+ color: $color-dark-on-surface-variant;
+ cursor: pointer;
+ padding: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+
+ &:hover {
+ background: rgba($color-dark-on-surface, 0.1);
+ color: $color-dark-on-surface;
+ }
+
+ &:active {
+ transform: scale(0.95);
+ }
+
+ .material-symbols {
+ font-size: 18px;
+ }
+ }
+
+ .counter {
+ margin-top: 4px;
+ padding-left: 16px;
+ font-size: 0.75rem;
+ color: $color-dark-on-surface-variant;
+ text-align: right;
+ }
+}
+
+// Alert Styles
+.alertContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 16px;
+
+ .alert {
+ padding: 12px 16px;
+ border-radius: 12px;
+ font-size: 0.9rem;
+ line-height: 1.5;
+ animation: slideInDown 0.3s ease;
+
+ &.alert-success {
+ background: rgba($color-dark-primary-container, 0.3);
+ color: $color-dark-on-primary-container;
+ border: 1px solid rgba($color-dark-primary, 0.3);
+ }
+
+ &.alert-danger {
+ background: rgba($color-dark-error-container, 0.3);
+ color: $color-dark-on-error-container;
+ border: 1px solid rgba($color-dark-error, 0.3);
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/chat/css/ChatInput.module.scss b/frontend/src/pages/chat/css/ChatInput.module.scss
index 40a5108..cdd1f52 100644
--- a/frontend/src/pages/chat/css/ChatInput.module.scss
+++ b/frontend/src/pages/chat/css/ChatInput.module.scss
@@ -116,7 +116,7 @@
width: 50px;
height: 50px;
border-radius: 50%;
- background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
+ background-color: $color-dark-primary;
color: $color-dark-on-primary;
border: 1px solid rgba($color-dark-primary, 0.5);
cursor: pointer;
diff --git a/frontend/src/pages/chat/css/Message.module.scss b/frontend/src/pages/chat/css/Message.module.scss
index 6b61916..f1cfe50 100644
--- a/frontend/src/pages/chat/css/Message.module.scss
+++ b/frontend/src/pages/chat/css/Message.module.scss
@@ -210,7 +210,7 @@
left: 0;
right: 0;
bottom: 0;
- background: linear-gradient(135deg, rgba($color-dark-primary, 0.05), rgba($color-dark-tertiary, 0.03));
+ background: linear-gradient(135deg, rgba(147, 51, 234, 0.05), rgba(99, 102, 241, 0.03));
pointer-events: none;
z-index: 0;
}
@@ -232,7 +232,7 @@
flex-direction: row-reverse;
.messageInner {
- background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
+ background: linear-gradient(135deg, #9333EA, #6366F1, #3B82F6);
color: $color-dark-on-primary;
border-top-right-radius: 5px;
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
diff --git a/frontend/src/pages/chat/css/callWindow.module.scss b/frontend/src/pages/chat/css/callWindow.module.scss
index 473058f..85602a1 100644
--- a/frontend/src/pages/chat/css/callWindow.module.scss
+++ b/frontend/src/pages/chat/css/callWindow.module.scss
@@ -29,7 +29,6 @@
height: 100vh;
background-color: rgba($color-dark-surface, 0.98);
backdrop-filter: blur(40px);
- -webkit-backdrop-filter: blur(40px);
border: none;
border-radius: 0;
cursor: default;
@@ -64,7 +63,6 @@
height: 300px;
background-color: rgba($color-dark-surface, 0.95);
backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
border: 2px solid rgba($color-dark-outline, 0.4);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
@@ -415,7 +413,6 @@
font-weight: 600;
border-radius: 8px;
backdrop-filter: blur(10px);
- -webkit-backdrop-filter: blur(10px);
}
&.localVideo {
@@ -455,7 +452,6 @@
color: $color-dark-on-primary;
border-radius: 8px;
backdrop-filter: blur(10px);
- -webkit-backdrop-filter: blur(10px);
pointer-events: none;
z-index: 1;
}
diff --git a/frontend/src/pages/chat/css/layout.module.scss b/frontend/src/pages/chat/css/layout.module.scss
index b07ecfb..165e9b3 100644
--- a/frontend/src/pages/chat/css/layout.module.scss
+++ b/frontend/src/pages/chat/css/layout.module.scss
@@ -4,13 +4,14 @@
.chatInterface {
height: 100%;
- background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%);
+ background: $color-dark-background;
+ // background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%);
position: relative;
overflow: hidden;
&::before {
content: '';
- position: fixed;
+ position: absolute;
top: 0;
left: 0;
right: 0;
@@ -20,7 +21,7 @@
radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.15) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.1) 0%, transparent 50%);
pointer-events: none;
- z-index: 0;
+ z-index: 10;
}
.allContainer {
diff --git a/frontend/src/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss
index d0e998d..932a64f 100644
--- a/frontend/src/pages/chat/css/left-panel.module.scss
+++ b/frontend/src/pages/chat/css/left-panel.module.scss
@@ -25,16 +25,25 @@
justify-content: center;
align-items: center;
padding: 16px;
+ user-select: none;
+
+ .logo {
+ $size: 35px;
+
+ width: $size;
+ height: $size;
+ margin-right: 8px;
+ }
.productName {
flex-grow: 1;
font-size: 1.8rem;
font-weight: 700;
- background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
- -webkit-background-clip: text;
+ background: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #C084FC, #7E22CE);
+ background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
- text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
+ text-shadow: 0 0 20px rgba(147, 51, 234, 0.5);
}
.profile {
@@ -66,45 +75,11 @@
}
}
- .chatTabs {
- margin-top: 5px;
- width: 100%;
- height: calc(100% - 80px);
- display: flex;
- flex-direction: column;
- min-height: 0; // prevent flex collapse when inner overflows
- --mdui-color-surface: $color-dark-surface-container;
- --mdui-color-surface-variant: transparent;
-
- img {
- width: 45px;
- height: 45px;
- border-radius: 20%;
- object-fit: cover;
- }
-
- mdui-tabs {
- height: 100%;
- display: flex;
- flex-direction: column;
- min-height: 0; // enable inner panel to scroll
- }
-
- mdui-tab-panel[active] {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0; // critical to avoid collapsing
- overflow: auto;
- }
-
- mdui-list {
- flex: 1;
- min-height: 0; // allow scroll area to size correctly
- overflow-y: auto;
- padding: 0;
- margin: 0;
- }
+ .unifiedChatsList {
+ flex: 1;
+ min-height: 0; // allow scroll area to size correctly
+ overflow-y: auto;
+ margin-top: 10px;
}
// Search container
@@ -134,6 +109,7 @@
padding: 32px;
color: $color-dark-on-surface-variant;
text-align: center;
+ overflow: hidden;
}
// Custom styling for search result images
diff --git a/frontend/src/pages/chat/css/profile-dialog.module.scss b/frontend/src/pages/chat/css/profile-dialog.module.scss
index e10b4c9..3a1d9a8 100644
--- a/frontend/src/pages/chat/css/profile-dialog.module.scss
+++ b/frontend/src/pages/chat/css/profile-dialog.module.scss
@@ -53,6 +53,9 @@
.usernameWithBadge {
gap: 0;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
.usernameInput {
background: none;
diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx
index dac200b..67c476d 100644
--- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx
+++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx
@@ -5,13 +5,14 @@ import { useState } from "react";
import { useAppState } from "@/pages/chat/state";
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
import styles from "@/pages/chat/css/left-panel.module.scss";
+import logoIcon from "@/images/logo.svg";
-export function ChatHeader() {
+export function ChatHeader({ headerRef }: { headerRef?: React.RefObject }) {
const { profileData } = useProfile();
const { setProfileDialog, user } = useAppState();
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
- const handleProfileClick = () => {
+ function handleProfileClick() {
setProfileDialog({
userId: user.currentUser?.id,
username: profileData?.username || "Пользователь",
@@ -26,7 +27,8 @@ export function ChatHeader() {
return (
<>
-
+
+
{PRODUCT_NAME}
diff --git a/frontend/src/pages/chat/ui/left/ChatTabs.tsx b/frontend/src/pages/chat/ui/left/ChatTabs.tsx
deleted file mode 100644
index 6560205..0000000
--- a/frontend/src/pages/chat/ui/left/ChatTabs.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useAppState, type ChatTabs } from "@/pages/chat/state";
-import { UnifiedChatsList } from "./UnifiedChatsList";
-import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material";
-import styles from "@/pages/chat/css/left-panel.module.scss";
-
-export function ChatTabs() {
- const { chat, setActiveTab } = useAppState();
-
- return (
-
- 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 c191875..3287784 100644
--- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx
+++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx
@@ -1,22 +1,21 @@
import { useAppState } from "@/pages/chat/state";
-import { useState } from "react";
+import { useRef, useState } from "react";
import { SettingsDialog } from "./settings/SettingsDialog";
import { UsernameSearch } from "./UsernameSearch";
-import { ChatTabs } from "./ChatTabs";
+import { UnifiedChatsList } from "./UnifiedChatsList";
import { ChatHeader } from "./ChatHeader";
-import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material";
+import { MaterialBottomAppBar, MaterialFab, MaterialIconButton, type MDUIBottomAppBar } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
-function BottomAppBar() {
+function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject }) {
const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useAppState();
return (
<>
-
+
onSettingsOpenChange(true)} />
-
-
+
(null);
+ const headerRef = useRef(null);
+ const bottomAppBarRef = useRef(null);
+
return (
-
-
+
);
}
diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx
index 3a4f709..fb40dad 100644
--- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx
+++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from "react";
+import { useState, useEffect, useCallback, useMemo } from "react";
import { useAppState } from "@/pages/chat/state";
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { API_BASE_URL } from "@/core/config";
@@ -36,18 +36,17 @@ interface DMConversation {
type ChatItem = PublicChat | DMConversation;
+const PUBLIC_CHAT: PublicChat = {
+ id: "general",
+ name: "Общий чат",
+ type: "public"
+};
+
export function UnifiedChatsList() {
const { user, switchToPublicChat, switchToDM, chat } = useAppState();
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
-
- const [publicChats] = useState
([
- { id: "general", name: "Общий чат", type: "public" },
- { id: "general2", name: "Общий чат 2", type: "public" }
- ]);
const [lastMessages, setLastMessages] = useState>({});
- const [allChats, setAllChats] = useState([]);
- // Load public chat last messages
const loadLastMessages = useCallback(async () => {
if (!user.authToken) return;
@@ -58,13 +57,9 @@ export function UnifiedChatsList() {
if (response.ok) {
const data = await response.json();
- if (data.messages && data.messages.length > 0) {
+ if (data.messages?.length > 0) {
const lastMessage = data.messages[data.messages.length - 1];
-
- setLastMessages({
- general: lastMessage,
- general2: lastMessage
- });
+ setLastMessages({ general: lastMessage });
}
}
} catch (error) {
@@ -72,7 +67,6 @@ export function UnifiedChatsList() {
}
}, [user.authToken]);
- // Load DM users when chats tab is active
useEffect(() => {
if (chat.activeTab === "chats") {
loadUsers();
@@ -80,79 +74,56 @@ export function UnifiedChatsList() {
}
}, [chat.activeTab, loadUsers, loadLastMessages]);
- // Combine public chats and DMs into one list
- useEffect(() => {
- const publicChatItems: ChatItem[] = publicChats.map(chat => ({
- ...chat,
- lastMessage: lastMessages[chat.id]
- }));
+ const allChats = useMemo(() => {
+ return [
+ ...dmUsers.map((user: DMUser) => ({
+ ...user,
+ userId: user.id,
+ type: "dm" as const
+ })),
+ {
+ ...PUBLIC_CHAT,
+ lastMessage: lastMessages[PUBLIC_CHAT.id]
+ }
+ ];
+ }, [lastMessages, dmUsers]);
- const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
- id: user.id,
- userId: user.id, // Add userId field
- username: user.username,
- display_name: user.display_name,
- profile_picture: user.profile_picture,
- online: user.online,
- type: "dm" as const,
- lastMessage: user.lastMessage,
- unreadCount: user.unreadCount,
- publicKey: user.publicKey
- }));
-
- // Combine and sort by last message timestamp (DMs first, then public chats)
- const combined = [...dmChatItems, ...publicChatItems];
- setAllChats(combined);
- }, [publicChats, lastMessages, dmUsers]);
-
- // WebSocket listener for public chat message updates
useEffect(() => {
if (!websocket) return;
- const handleWebSocketMessage = (e: MessageEvent) => {
+ function handleWebSocketMessage(e: MessageEvent) {
try {
const msg = JSON.parse(e.data);
if (msg.type === "newMessage") {
const newMessage = msg.data as Message;
- // Update all public chats with the new message
- setLastMessages(prev => {
- const updated = { ...prev };
- publicChats.forEach(chat => {
- updated[chat.id] = newMessage;
- });
- return updated;
- });
+ setLastMessages(prev => ({
+ ...prev,
+ [PUBLIC_CHAT.id]: newMessage
+ }));
} else if (msg.type === "messageEdited") {
const editedMessage = msg.data as Message;
- // Update only if the edited message is the current last message
setLastMessages(prev => {
- const updated = { ...prev };
- publicChats.forEach(chat => {
- if (updated[chat.id]?.id === editedMessage.id) {
- updated[chat.id] = editedMessage;
- }
- });
- return updated;
+ if (prev[PUBLIC_CHAT.id]?.id === editedMessage.id) {
+ return {
+ ...prev,
+ [PUBLIC_CHAT.id]: editedMessage
+ };
+ }
+ return prev;
});
} else if (msg.type === "messageDeleted") {
const deletedMessageId = msg.data?.message_id;
- let needsReload = false;
-
setLastMessages(prev => {
- const updated = { ...prev };
- publicChats.forEach(chat => {
- if (updated[chat.id]?.id === deletedMessageId) {
- updated[chat.id] = undefined;
- needsReload = true;
- }
- });
- return updated;
+ if (prev[PUBLIC_CHAT.id]?.id === deletedMessageId) {
+ loadLastMessages();
+ return {
+ ...prev,
+ [PUBLIC_CHAT.id]: undefined
+ };
+ }
+ return prev;
});
-
- if (needsReload) {
- loadLastMessages();
- }
}
} catch (error) {
console.error("Failed to handle WebSocket message in UnifiedChatsList:", error);
@@ -161,45 +132,33 @@ export function UnifiedChatsList() {
websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage);
- }, [publicChats, loadLastMessages]);
+ }, [loadLastMessages]);
- // Subscribe to online status for all DM users
useEffect(() => {
- const dmUsers = allChats.filter(chat => chat.type === "dm") as DMConversation[];
-
- // Subscribe to all DM users
dmUsers.forEach(dmUser => {
onlineStatusManager.subscribe(dmUser.id);
});
- // Cleanup function to unsubscribe from all users
return () => {
dmUsers.forEach(dmUser => {
onlineStatusManager.unsubscribe(dmUser.id);
});
};
- }, [allChats]);
+ }, [dmUsers]);
function formatPublicChatMessage(chatId: string): string {
const lastMessage = lastMessages[chatId];
- if (!lastMessage) {
- return "";
- }
+ if (!lastMessage) return "";
const isCurrentUser = lastMessage.user_id === user.currentUser?.id;
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
-
- const maxContentLength = 50 - prefix.length;
- const content = lastMessage.content.length > maxContentLength
- ? lastMessage.content.substring(0, maxContentLength) + "..."
+ const maxLength = 50 - prefix.length;
+ const content = lastMessage.content.length > maxLength
+ ? lastMessage.content.substring(0, maxLength) + "..."
: lastMessage.content;
return prefix + content;
- }
-
- async function handlePublicChatClick(chatName: string) {
- await switchToPublicChat(chatName);
- }
+ };
async function handleDMClick(dmConversation: DMConversation) {
if (!dmConversation.publicKey) {
@@ -207,12 +166,11 @@ export function UnifiedChatsList() {
if (!authToken) return;
const publicKey = await fetchUserPublicKey(dmConversation.id, authToken);
- if (publicKey) {
- dmConversation.publicKey = publicKey;
- } else {
+ if (!publicKey) {
console.error("Failed to get public key for user:", dmConversation.id);
return;
}
+ dmConversation.publicKey = publicKey;
}
await switchToDM({
@@ -222,26 +180,27 @@ export function UnifiedChatsList() {
profilePicture: dmConversation.profile_picture,
online: dmConversation.online || false
});
- }
+ };
if (isLoadingUsers) {
return ;
}
return (
-
+
{allChats.map((chat) => {
if (chat.type === "public") {
+ const formattedMessage = formatPublicChatMessage(chat.id);
return (
handlePublicChatClick(chat.name)}
+ onClick={() => switchToPublicChat(chat.name)}
style={{ cursor: "pointer" }}
>
- {formatPublicChatMessage(chat.id) && (
+ {formattedMessage && (
- {formatPublicChatMessage(chat.id)}
+ {formattedMessage}
)}
);
- } else {
- return (
- handleDMClick(chat)}
- style={{ cursor: "pointer" }}
- >
-
- {chat.display_name}
-
-
-
- {chat.lastMessage || "Нет сообщений"}
-
-
-

{
- e.target.src = defaultAvatar;
- }}
- />
-
-
- {chat.unreadCount > 0 && (
-
- {chat.unreadCount}
-
- )}
-
- );
}
+
+ return (
+ handleDMClick(chat)}
+ style={{ cursor: "pointer" }}
+ >
+
+ {chat.display_name}
+
+
+
+ {chat.lastMessage || "Нет сообщений"}
+
+
+

{
+ 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 b27c861..9ed8dae 100644
--- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx
+++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx
@@ -8,7 +8,7 @@ import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import { OnlineStatus } from "@/pages/chat/ui/right/OnlineStatus";
import defaultAvatar from "@/images/default-avatar.png";
import SearchBar from "@/core/components/SearchBar";
-import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
+import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem, type MDUIBottomAppBar } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
interface SearchUser extends User {
@@ -16,7 +16,13 @@ interface SearchUser extends User {
verified?: boolean;
}
-export function UsernameSearch() {
+export interface UsernameSearchProps {
+ containerRef: React.RefObject;
+ headerRef?: React.RefObject;
+ bottomAppBarRef?: React.RefObject;
+}
+
+export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) {
const { user, switchToDM, chat } = useAppState();
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState([]);
@@ -165,6 +171,9 @@ export function UsernameSearch() {
icon="arrow_back--outlined"
/>
) : "search--outlined"}
+ containerRef={containerRef}
+ headerRef={headerRef}
+ bottomAppBarRef={bottomAppBarRef}
>
{isSearching && (
diff --git a/frontend/src/pages/home/home.module.scss b/frontend/src/pages/home/home.module.scss
index 40327db..7284c6a 100644
--- a/frontend/src/pages/home/home.module.scss
+++ b/frontend/src/pages/home/home.module.scss
@@ -58,7 +58,7 @@
font-weight: 700;
margin: 0;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
- -webkit-background-clip: text;
+ background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
@@ -99,7 +99,7 @@
line-height: 1.1;
margin-bottom: 1.5rem;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary, $color-dark-secondary);
- -webkit-background-clip: text;
+ background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 30px rgba($color-dark-primary, 0.5);
@@ -246,7 +246,7 @@
font-weight: 700;
margin-bottom: 3rem;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
- -webkit-background-clip: text;
+ background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
@@ -348,7 +348,7 @@
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
- -webkit-background-clip: text;
+ background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
@@ -387,7 +387,7 @@
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
- -webkit-background-clip: text;
+ background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
diff --git a/frontend/src/pages/not-found/not-found.module.scss b/frontend/src/pages/not-found/not-found.module.scss
index 82cefbb..69a33c9 100644
--- a/frontend/src/pages/not-found/not-found.module.scss
+++ b/frontend/src/pages/not-found/not-found.module.scss
@@ -3,7 +3,7 @@
align-items: center;
justify-content: center;
min-height: 100vh;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ background: linear-gradient(135deg, #9333EA 0%, #6366F1 100%);
padding: 2rem;
}
@@ -42,7 +42,7 @@
.errorCode {
font-size: 6rem;
font-weight: 900;
- color: #667eea;
+ color: #9333EA;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
@@ -58,7 +58,7 @@
display: flex;
align-items: center;
justify-content: center;
- color: #667eea;
+ color: #9333EA;
opacity: 0.7;
}
diff --git a/frontend/src/utils/material.tsx b/frontend/src/utils/material.tsx
index bd698fc..31203d8 100644
--- a/frontend/src/utils/material.tsx
+++ b/frontend/src/utils/material.tsx
@@ -40,7 +40,7 @@ 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");
+setColorScheme("#9333EA");
type BasePropCustomization = Override, {
ref?: Ref;