mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 19:45:05 +03:00
Merge branch 'feature/auth-redesign'
This commit is contained in:
@@ -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
|
||||
- Put SCSS into one folder per page
|
||||
|
||||
## Animations with Framer Motion
|
||||
- Don't use variants if they are used only once
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+35
-11
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Loading...</title>
|
||||
<link rel="icon" href="./src/images/logo.png" />
|
||||
<link rel="icon" href="./src/images/logo.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+57
-11
@@ -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: <HomePage /> },
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/register", element: <RegisterPage /> },
|
||||
{ path: "/auth", element: <AuthPage /> },
|
||||
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
||||
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
||||
{ path: "/download-app", element: <DownloadAppPage /> },
|
||||
{
|
||||
path: "/chat",
|
||||
@@ -66,6 +68,54 @@ function SmartCatchAll() {
|
||||
}
|
||||
}
|
||||
|
||||
function AnimatedRoutes() {
|
||||
const location = useLocation();
|
||||
const prevPathnameRef = useRef(location.pathname);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="sync" initial={false}>
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
onAnimationStart={() => {
|
||||
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
|
||||
}}
|
||||
>
|
||||
<Routes location={location}>
|
||||
{routeConfig.map((route, index) => (
|
||||
<Route key={index} path={route.path} element={route.element} />
|
||||
))}
|
||||
</Routes>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { restoreUserFromStorage, user } = useAppState();
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
@@ -80,11 +130,7 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<ElectronTitleBar />
|
||||
<div id="main-wrapper">
|
||||
<Routes>
|
||||
{routeConfig.map((route, index) => (
|
||||
<Route key={index} path={route.path} element={route.element} />
|
||||
))}
|
||||
</Routes>
|
||||
<AnimatedRoutes />
|
||||
</div>
|
||||
{user.isSuspended && (
|
||||
<SuspensionDialog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import styles from "./css/searchBar.module.scss";
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
import { MaterialIcon, type MDUIBottomAppBar } from "@/utils/material";
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder: string;
|
||||
@@ -11,6 +11,9 @@ interface SearchBarProps {
|
||||
onToggleExpanded: () => void;
|
||||
leftIcon?: string | React.ReactNode;
|
||||
rightIcon?: string | React.ReactNode;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
headerRef?: React.RefObject<HTMLElement | null>;
|
||||
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
|
||||
}
|
||||
|
||||
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<string>("48px");
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const parentContainerRef = useRef<HTMLDivElement>(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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Section - Only visible when expanded */}
|
||||
{isExpanded && (
|
||||
<div className={styles.searchResults}>
|
||||
{/* Results Section - Visible during expansion and collapse transition */}
|
||||
{showResults && (
|
||||
<div
|
||||
className={styles.searchResults}
|
||||
style={{ overflowY: isTransitioning ? "hidden" : "auto" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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,
|
||||
|
||||
+201
-78
@@ -44,6 +44,18 @@ let globalMessageHandler: ((response: WebSocketMessage<any>) => 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<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
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<void> {
|
||||
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<any> = 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<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
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<any> = 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);
|
||||
setupEventHandlers();
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 824 KiB |
@@ -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 (
|
||||
<div className={styles.authContainer}>
|
||||
<div className={styles.authCard}>
|
||||
<div className={styles.gradientBackground} />
|
||||
<motion.div
|
||||
className={styles.authCard}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: 10
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,13 +47,63 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
|
||||
const iconName = typeof icon == "string" ? icon : icon.name;
|
||||
|
||||
return (
|
||||
<div className={styles.authHeader}>
|
||||
<motion.div
|
||||
className={styles.authHeader}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -10
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.1,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<h2>
|
||||
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
|
||||
<motion.span
|
||||
className={`material-symbols ${iconType} large`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
rotate: -10
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotate: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.2,
|
||||
ease: "easeOut"
|
||||
}}
|
||||
>
|
||||
{iconName}
|
||||
</motion.span>
|
||||
{title}
|
||||
</h2>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
<motion.p
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 10
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,11 +115,40 @@ export interface Alert {
|
||||
}
|
||||
|
||||
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
||||
const displayAlerts = alerts.slice(-3);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{alerts.slice(-3).map((alert, i) => {
|
||||
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
|
||||
})}
|
||||
<div className={styles.alertContainer}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{displayAlerts.map((alert, i) => (
|
||||
<motion.div
|
||||
key={`${i}-${alert.message}`}
|
||||
className={`${styles.alert} alert-${alert.type}`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
scale: 0.95
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
scale: 0.95
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
layout
|
||||
>
|
||||
{alert.message}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const loginFormRef = useRef<HTMLDivElement>(null);
|
||||
const registerFormRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState<number | "auto">("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<HTMLDivElement | null>,
|
||||
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 (
|
||||
<AuthContainer>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
||||
transition: "height 0.3s ease"
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="sync" custom={direction}>
|
||||
{currentMode === "login" ? (
|
||||
<motion.div
|
||||
key="login"
|
||||
ref={loginFormRef}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
||||
className={styles.formWrapper}
|
||||
>
|
||||
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="register"
|
||||
ref={registerFormRef}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
||||
className={styles.formWrapper}
|
||||
>
|
||||
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -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<AuthTextFieldHandle, AuthTextFieldProps>(
|
||||
({
|
||||
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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
const hasError = false; // Can be extended for validation
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`${styles.authTextField} ${className}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
whileFocus={{ scale: 1.01 }}
|
||||
>
|
||||
<div className={`${styles.fieldContainer} ${isFocused ? styles.focused : ""} ${hasError ? styles.error : ""} ${!icon ? styles.noIcon : ""} ${togglePassword && type === "password" ? styles.hasToggle : ""}`}>
|
||||
{icon && (
|
||||
<span className={`material-symbols filled ${styles.fieldIcon}`}>
|
||||
{icon.replace("--filled", "").replace("--outlined", "")}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.inputWrapper}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={displayType}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
autoComplete={autocomplete}
|
||||
required={required}
|
||||
maxLength={maxlength}
|
||||
placeholder={label + (required ? " *" : "")}
|
||||
className={styles.input}
|
||||
/>
|
||||
</div>
|
||||
{togglePassword && type === "password" && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span className="material-symbols filled">
|
||||
{showPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{counter && maxlength && (
|
||||
<div className={styles.counter}>
|
||||
{charCount} / {maxlength}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AuthTextField.displayName = "AuthTextField";
|
||||
@@ -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<Alert[]>([]);
|
||||
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<AuthTextFieldHandle>(null);
|
||||
const passwordElement = useRef<AuthTextFieldHandle>(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 (
|
||||
<>
|
||||
<AuthHeader
|
||||
icon="login"
|
||||
title="Добро пожаловать!"
|
||||
subtitle="Войдите в свой аккаунт"
|
||||
/>
|
||||
<div className={styles.authBody}>
|
||||
<AlertsContainer alerts={alerts} />
|
||||
<motion.form onSubmit={handleSubmit}>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={loginFieldVariants}
|
||||
transition={loginFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="@Имя пользователя"
|
||||
name="username"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
required
|
||||
ref={usernameElement} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={loginFieldVariants}
|
||||
transition={loginFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="Пароль"
|
||||
name="password"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
</motion.div>
|
||||
|
||||
<div className={styles.authButtons}>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={loginButtonVariants}
|
||||
transition={loginButtonTransition}
|
||||
>
|
||||
<MaterialButton type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Вход..." : "Войти"}
|
||||
</MaterialButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.form>
|
||||
|
||||
<p className={styles.registerLink}>
|
||||
Ещё нет аккаунта?
|
||||
<a
|
||||
href="#"
|
||||
className="link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSwitchMode();
|
||||
}}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Alert[]>([]);
|
||||
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<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
|
||||
<div className={styles.authBody}>
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
|
||||
// Initialize notifications
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(data.token);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
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();
|
||||
|
||||
// Check for suspension
|
||||
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; // Don't show alert, SuspensionDialog will be shown
|
||||
}
|
||||
|
||||
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
|
||||
<MaterialTextField
|
||||
label="@Имя пользователя"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
required
|
||||
ref={usernameElement} />
|
||||
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
|
||||
<MaterialButton type="submit">Войти</MaterialButton>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Ещё нет аккаунта?
|
||||
<a
|
||||
href="#"
|
||||
className="link"
|
||||
onClick={() => navigate("/register")}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -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<Alert[]>([]);
|
||||
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<AuthTextFieldHandle>(null);
|
||||
const usernameElement = useRef<AuthTextFieldHandle>(null);
|
||||
const passwordElement = useRef<AuthTextFieldHandle>(null);
|
||||
const confirmPasswordElement = useRef<AuthTextFieldHandle>(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 (
|
||||
<>
|
||||
<AuthHeader
|
||||
icon="person_add"
|
||||
title="Регистрация"
|
||||
subtitle="Создайте новый аккаунт"
|
||||
/>
|
||||
<div className={styles.authBody}>
|
||||
<AlertsContainer alerts={alerts} />
|
||||
<motion.form onSubmit={handleSubmit}>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerFieldVariants}
|
||||
transition={registerFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="Отображаемое имя"
|
||||
name="display_name"
|
||||
icon="badge--filled"
|
||||
autocomplete="name"
|
||||
maxlength={64}
|
||||
counter
|
||||
required
|
||||
ref={displayNameElement} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerFieldVariants}
|
||||
transition={registerFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="@Имя пользователя"
|
||||
name="username"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
maxlength={20}
|
||||
counter
|
||||
required
|
||||
ref={usernameElement} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerFieldVariants}
|
||||
transition={registerFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="Пароль"
|
||||
name="password"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerFieldVariants}
|
||||
transition={registerFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="Подтвердите пароль"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={confirmPasswordElement} />
|
||||
</motion.div>
|
||||
|
||||
<div className={styles.authButtons}>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerButtonVariants}
|
||||
transition={registerButtonTransition}
|
||||
>
|
||||
<MaterialIconButton icon="arrow_back" onClick={onSwitchMode} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerButtonVariants}
|
||||
transition={registerButtonTransition}
|
||||
>
|
||||
<MaterialButton type="submit" disabled={isLoading} loading={isLoading} icon="person_add">
|
||||
{isLoading ? "Регистрация..." : "Зарегистрироваться"}
|
||||
</MaterialButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</motion.form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Alert[]>([]);
|
||||
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<TextField>(null);
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
const confirmPasswordElement = useRef<TextField>(null);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
|
||||
<div className={styles.authBody}>
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Validate username format (only English letters, numbers, dashes, underscores)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 5 || password.length > 50) {
|
||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
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", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Отображаемое имя"
|
||||
name="display_name"
|
||||
variant="outlined"
|
||||
icon="badge--filled"
|
||||
autocomplete="name"
|
||||
maxlength={64}
|
||||
counter
|
||||
required
|
||||
ref={displayNameElement} />
|
||||
<MaterialTextField
|
||||
label="@Имя пользователя"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
maxlength={20}
|
||||
counter
|
||||
required
|
||||
ref={usernameElement} />
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
<MaterialTextField
|
||||
label="Подтвердите пароль"
|
||||
name="confirm_password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={confirmPasswordElement} />
|
||||
|
||||
<MaterialButton type="submit">Зарегистрироваться</MaterialButton>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Уже есть аккаунт?
|
||||
<a
|
||||
href="#"
|
||||
id="login-link"
|
||||
className="link"
|
||||
onClick={() => navigate("/login")}>
|
||||
Войдите
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
|
||||
.usernameWithBadge {
|
||||
gap: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.usernameInput {
|
||||
background: none;
|
||||
|
||||
@@ -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<HTMLElement | null> }) {
|
||||
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 (
|
||||
<>
|
||||
<header className={styles.chatHeaderLeft}>
|
||||
<header className={styles.chatHeaderLeft} ref={headerRef}>
|
||||
<img src={logoIcon} alt="Logo" className={styles.logo} />
|
||||
<div className={styles.productName}>{PRODUCT_NAME}</div>
|
||||
<div className={styles.profile}>
|
||||
<a href="#" id="profile-open" onClick={handleProfileClick}>
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.chatTabs}>
|
||||
<MaterialTabs
|
||||
value={chat.activeTab}
|
||||
full-width
|
||||
onChange={(e) => setActiveTab(e.target.value as ChatTabs)}>
|
||||
<MaterialTab value="chats">
|
||||
Чаты
|
||||
</MaterialTab>
|
||||
<MaterialTab value="channels">
|
||||
Каналы
|
||||
</MaterialTab>
|
||||
<MaterialTab value="contacts">
|
||||
Контакты
|
||||
</MaterialTab>
|
||||
|
||||
<MaterialTabPanel slot="panel" value="chats">
|
||||
<UnifiedChatsList />
|
||||
</MaterialTabPanel>
|
||||
<MaterialTabPanel slot="panel" value="channels">Скоро будет...</MaterialTabPanel>
|
||||
<MaterialTabPanel slot="panel" value="contacts">Скоро будет...</MaterialTabPanel>
|
||||
</MaterialTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<MDUIBottomAppBar | null> }) {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
const { logout } = useAppState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaterialBottomAppBar>
|
||||
<MaterialBottomAppBar ref={bottomAppBarRef}>
|
||||
<MaterialIconButton icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
|
||||
<MaterialIconButton icon="group_add--filled" />
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
<MaterialIconButton
|
||||
icon="logout--filled"
|
||||
id="logout-btn"
|
||||
@@ -30,14 +29,18 @@ function BottomAppBar() {
|
||||
}
|
||||
|
||||
export function LeftPanel() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const bottomAppBarRef = useRef<MDUIBottomAppBar>(null);
|
||||
|
||||
return (
|
||||
<div className={styles.chatList} id="chat-list">
|
||||
<ChatHeader />
|
||||
<div className={styles.chatList} ref={containerRef}>
|
||||
<ChatHeader headerRef={headerRef} />
|
||||
<div className={styles.searchContainer}>
|
||||
<UsernameSearch />
|
||||
<UsernameSearch containerRef={containerRef} headerRef={headerRef} bottomAppBarRef={bottomAppBarRef} />
|
||||
</div>
|
||||
<ChatTabs />
|
||||
<BottomAppBar />
|
||||
<UnifiedChatsList />
|
||||
<BottomAppBar bottomAppBarRef={bottomAppBarRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<PublicChat[]>([
|
||||
{ id: "general", name: "Общий чат", type: "public" },
|
||||
{ id: "general2", name: "Общий чат 2", type: "public" }
|
||||
]);
|
||||
const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({});
|
||||
const [allChats, setAllChats] = useState<ChatItem[]>([]);
|
||||
|
||||
// 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<ChatItem[]>(() => {
|
||||
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 <MaterialCircularProgress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MaterialList>
|
||||
<MaterialList className={styles.unifiedChatsList}>
|
||||
{allChats.map((chat) => {
|
||||
if (chat.type === "public") {
|
||||
const formattedMessage = formatPublicChatMessage(chat.id);
|
||||
return (
|
||||
<MaterialListItem
|
||||
key={`public-${chat.id}`}
|
||||
headline={chat.name}
|
||||
onClick={() => handlePublicChatClick(chat.name)}
|
||||
onClick={() => switchToPublicChat(chat.name)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{formatPublicChatMessage(chat.id) && (
|
||||
{formattedMessage && (
|
||||
<span slot="description" className={styles.listDescription}>
|
||||
{formatPublicChatMessage(chat.id)}
|
||||
{formattedMessage}
|
||||
</span>
|
||||
)}
|
||||
<img
|
||||
@@ -257,50 +216,50 @@ export function UnifiedChatsList() {
|
||||
/>
|
||||
</MaterialListItem>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<MaterialListItem
|
||||
key={`dm-${chat.id}`}
|
||||
headline={chat.display_name}
|
||||
onClick={() => handleDMClick(chat)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div slot="headline" className="dm-list-headline">
|
||||
{chat.display_name}
|
||||
<StatusBadge
|
||||
verified={chat.verified || false}
|
||||
userId={chat.userId}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<span slot="description" className={styles.listDescription}>
|
||||
{chat.lastMessage || "Нет сообщений"}
|
||||
</span>
|
||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.display_name}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={chat.id} />
|
||||
</div>
|
||||
{chat.unreadCount > 0 && (
|
||||
<MaterialBadge slot="end-icon">
|
||||
{chat.unreadCount}
|
||||
</MaterialBadge>
|
||||
)}
|
||||
</MaterialListItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MaterialListItem
|
||||
key={`dm-${chat.id}`}
|
||||
headline={chat.display_name}
|
||||
onClick={() => handleDMClick(chat)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<div slot="headline" className="dm-list-headline">
|
||||
{chat.display_name}
|
||||
<StatusBadge
|
||||
verified={chat.verified || false}
|
||||
userId={chat.userId}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<span slot="description" className={styles.listDescription}>
|
||||
{chat.lastMessage || "Нет сообщений"}
|
||||
</span>
|
||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.display_name}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={chat.id} />
|
||||
</div>
|
||||
{chat.unreadCount > 0 && (
|
||||
<MaterialBadge slot="end-icon">
|
||||
{chat.unreadCount}
|
||||
</MaterialBadge>
|
||||
)}
|
||||
</MaterialListItem>
|
||||
);
|
||||
})}
|
||||
</MaterialList>
|
||||
);
|
||||
|
||||
@@ -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<HTMLElement | null>;
|
||||
headerRef?: React.RefObject<HTMLElement | null>;
|
||||
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
|
||||
}
|
||||
|
||||
export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) {
|
||||
const { user, switchToDM, chat } = useAppState();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchUser[]>([]);
|
||||
@@ -165,6 +171,9 @@ export function UsernameSearch() {
|
||||
icon="arrow_back--outlined"
|
||||
/>
|
||||
) : "search--outlined"}
|
||||
containerRef={containerRef}
|
||||
headerRef={headerRef}
|
||||
bottomAppBarRef={bottomAppBarRef}
|
||||
>
|
||||
{isSearching && (
|
||||
<div className={styles.searchLoading}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Tag extends keyof React.JSX.IntrinsicElements, Type> = Override<ComponentPropsWithoutRef<Tag>, {
|
||||
ref?: Ref<Type>;
|
||||
|
||||
Reference in New Issue
Block a user